数据结构与算法(汇总)

Ch0 目录

代码编写使用C++。
部分内容参考网上资料。
习题部分较少。

Ch1 绪论: https://www.cnblogs.com/LieDra/p/13167407.html
Ch2 线性表: https://www.cnblogs.com/LieDra/p/13205291.html
Ch3 栈和队列: https://www.cnblogs.com/LieDra/p/13209160.html
Ch4 串: https://www.cnblogs.com/LieDra/p/13210229.html
Ch5 数组:https://www.cnblogs.com/LieDra/p/13260780.html
Ch6 递归:https://www.cnblogs.com/LieDra/p/13260787.html
Ch7 树和二叉树:https://www.cnblogs.com/LieDra/p/13260801.html
Ch8 图:https://www.cnblogs.com/LieDra/p/13260810.html
Ch9 排序:https://www.cnblogs.com/LieDra/p/13260829.html
Ch10 查找:https://www.cnblogs.com/LieDra/p/13260836.html

author:LieDra

Ch1 绪论


0x01 绪论


程序 = 数据结构+算法
同样的数据对象,用不同的数据结构来表示,运算效率可能有明显差异。

数据结构研究内容

表:学生选课课表,人员表,图书检索等
树:文件系统磁盘目录,人机对弈派生格局等
图:地图导航(最短路径等场景)
共性:研究非数值计算,数学模型是表、数、图之类的有逻辑关系的数据,以及研究操作对象之间的关系和操作。
数据结构是一门研究非数值计算的程序设计问题中计算机的操作对象以及它们之间的关系和操作等的学科。


0x02 基本概念和术语


数据、数据元素、数据项、数据对象
数据:信息载体,各种符号集合。人们利用文字符号、数字符号以及其他规定的符号对现实世界的事物及其活动所做的抽象描述。
数据元素:是表示一个事物的一组数据。数据的基本单位,在计算机程序中通常作为一个整体。(也叫做元素、记录、结点或顶点)
数据项:构成数据元素的不可分割的最小单位,一个数据元素可以由多个数据项构成。
数据>数据元素>数据项

例子

例如,
学生信息可包括学生的学号、姓名、性别、年龄等数据。
这些数据构成学生情况的描述的数据项;
包括学号、姓名、性别、年龄等数据项的一组数据就构成学生信息的一个数据元素。

数据对象:性质相同的数据元素的集合(如整数,字母字符,学籍表等)是数据的子集。
数据结构:数据元素相互之间的关系成为结构。包括逻辑关系,物理结构(存储结构),数据的运算和实现(操作)。
逻辑结构:与存储无关(数据元素之间的相互联系方式)
物理结构:存储器中的结构(数据元素在计算机中的存储方式)
逻辑结构的种类:线性结构(1对1,线性表、栈、队列、串)和非线性结构(1对多或多对多,树,图)或者划分为集合、线性、树、图四种结构。
存储结构的种类:

  • 顺序存储结构(一组连续存储单元依次存储数据元素,数组实现,特点是逻辑上相邻的数据元素在物理存储上也相邻)
  • 链式存储结构(不连续的存储单元,c/c++中用指针来实现,使用指针把相互直接关联的结点(即直接前驱结点或直接后继结点)链接起来,其特点是逻辑上相邻的数据元素在物理上不一定相邻)
  • 索引存储结构(存结点同时还建立附加的索引表)
  • 散列存储结构(根据某种公式直接计算结点存储地址,查找章节详细介绍)

数据类型:明显或隐含规定了变量的取值范围以及在该范围内所允许进行的操作。

抽象数据类型:没有确切定义的数据类型。是一个数学模型以及定义在此数学模型上的一组操作。包含数据对象,数据关系,数据操作的三元组。在程序语言中用已有数据类型定义存储结构,用函数定义相关的数据操作。

数据结构三个方面.png


0x03算法与算法分析


算法:描述求解问题方法的操作步骤集合。在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作。
算法描述可以用自然语言,也可以用流程图、NS流程图等描述。还可以用伪代码描述,更可以用程序代码直接描述。

  • 文字形式:用中文或英文这样的文字来描述算法。
  • 伪码形式:用一种仿程序设计语言的语言来描述算法。
  • 程序设计语言形式:用某种程序设计语言描述算法。其优点是算法不用修改,直接作为程序语句输入计算机,计算机能调用和运行。

例1-1
设计一个把存储在数组中的有n个抽象数据元素a0,a1,…,an-1逆置的算法,即要求逆置后的数组中数据元素序列为an-1 , … , a1 , a0,并要求原数组中的数据元素值不能被改变。

void Reverse(int n,DataType a[],DataType b[])
{
	int i;
	for(i=0;i<n;i++)
	b[i]=a[n-1-i];     //把数组a的元素逆置后赋给数组b
}

例1-2
设计一个把存储在数组中的有n个抽象数据元素a0,a1,…,an-1就地逆置的算法,即要求逆置后的数组中数据元素序列为an-1 , … , a1 , a0,并要求原数组中的数据元素值被改变。

void Reverse(int n,DataType a[])
{
	int i,m=n/2;
	DataType temp;
	for(i=0;i<m;i++)     //进行m次调换
	{
		temp=a[i];
		a[i]=a[n-1-i];
		a[n-1-i]=temp;
	}
}

算法特性:
  • 输入性:具有0个或者多个输入。
  • 输出性:产生1个或多个有意义的输出。
  • 有限性:算法执行有穷步之后结束,且每一步有穷时间内完成。执行语句的序列是有限的。
  • 确定性:无二义性,对于相同的输入只能得到相同的输出(带随机的除外)。每一条语句的含义明确,无二义性。
  • 可行性(可执行性):算法是可执行的,每一条语句都应在有限的时间内完成。
算法设计的要求:
  • 正确性:对于一切合法输入都能得到满足要求的结果
  • 可读性:应易于人的理解
  • 健壮性:鲁棒性,对于非法数据和错误,应恰当做出反应或处理。
  • 高效性:尽量少的时间和空间要求(尽量高时间效率、高空间效率)
算法效率

时间效率和空间效率(常矛盾)
算法时间效率:依据程序消耗时间度量(事后统计和事前分析两种度量方法)

  • 事后统计:运行完后根据花费时间得到。设计测试数据,运行程序,统计时间。缺陷是必须运行程序,测试数据设计困难。
  • 事前分析:根据单个操作花费时间(假设执行每条语句所需时间均为单位时间)与操作执行次数计算算法运行时间。

程序运行消耗时间的有关因素:

  • 所用程序设计语言
  • 编译产生的机器语言代码质量
  • 机器执行指令的速度
  • 问题规模

一般比较算法的时间效率时,仅比较数量级。即算法的渐进时间复杂度O(f(n))。

  • O(2n + 3) = O(2n)
  • O(2n^2) = O(n^2)
  • O(n^3) > O(n^2)

n在排序中可以表示记录数,在矩阵中可以表示矩阵阶数,多项式中可以表示多项式项数,树中可以表示为树结点数目······。
分析方法:找出执行次数(频度)最大的语句(基本是找循环最深),计算频度得到f(n),得到渐进时间复杂度T(n)=O(f(n))。

最坏时间复杂度,平均时间复杂度,最好时间复杂度。一般考虑最坏时间复杂度。

例1-3
n*n矩阵相乘(三重循环)量级为n的三次方。

for(i=0;i<n;i++)
for(j=0;j<n;j++)
{
	c[i][j]=0;                            //基本语句1
	for(k=0;k<n;k++)
	      c[i][j]=c[i][j]+a[i][k]*b[k][j];  //基本语句2
}
//设基本语句的执行次数为f(n),有f(n)=c1×n2次方+c2×n3次方,最后得到大O。

例1-4
下边的算法是用冒泡排序法对数字a中的n个整数类型的数据元素(a[0]~a[n-1]),从小到大进行排序,求该算法的时间复杂度。

void BubbleSort(int a[],int n)
{	int i,j,flag=1;
	int temp;
	for(i=1;i<n&&flag==1;i++)
	{		flag=0;
		for(j=0;j<n-i;j++)
		{	if(a[j]>a[j+1])
			{	flag=1;
				temp=a[j];
				a[j]=a[j+1];
				a[j+1]=temp;
			}
		}
	}
}
/*第一次交换n-1次,第二次交换n-2次……..1次,用等差数列求和公式求和,每次交换有4个赋值操作,所以乘4,另外flag=0执行了n次。
最坏情况下为f(n) ≈n+2* n的2次方,大O为n的二次方
*/

例1-5
下面的算法是在一个有n个数据元素的数组a中删除第I个位置的数组元素,要求当删除成功时数组元素个数减1,求该算法的时间复杂度。其中数组下标从0至n-1。

int Delete(int a[],int &n,int i)
{
	int j;
	if(i<0||i>=n) return 0;                  //删除位置错误,失败返回
	for(j=i+1;j<n;j++) a[j-1]=a[j];   //顺次移位填补
	n--;                                                //数组元素个数减1
	return 1;                                      //删除成功返回
}
/*
对于顺序表,所谓删除就是把其后元素依次前移。
这里其实for循环里面值从0---n-1,求平均得到平均时间复杂度大O为O(n)。
*/

算法时间效率的比较。
O(1) < O(log n ) < O(n) < O(nlog n ) < O(n方) < < O(n三次方) < ··· < O(n的k次方) < O(2的n次方)
(即常数阶-对数阶-线性阶-线性对数阶-平方阶-立方阶-k次方阶-指数阶)

算法空间复杂度,算法所需存储空间的度量。(包括算法本身占据空间,和运行时使用的辅助空间)。基本与时间类似,一般不太考虑。

算法的书写规范:主要是算法的符号命名和书写格式上。类似于代码规范。

Ch2 线性表


0x01 线性表的定义和特点


定义

线性表是一种可以在任意位置进行插入和删除数据元素操作、由n(n≥0)个相同类型数据元素a0,a1,…,an-1组成的线性结构。是最常用且最简单的一种数据结构,它是n个数据元素的有限序列。
操作集合:初始化线性表、插入数据元素、求当前数据元素个数、删除数据元素、取数据元素等。
实现方式:线性(数组)、链式(指针)。
注意,下两节所说的存取操作和插入删除操作指的是在某个位置的操作,不是对某个值的操作。

0x02 线性表的顺序表示和实现(顺序表)


定义

使用数组,用一段地址连续的存储单元依次存储线性表的数据元。(一般采用静态数组方法。

编号地址

存储器中的每个存储单元都有自己的编号,这个编号称为地址。

存储位置公式

每个数据元素,不管它是整型,实型还是字符型,都需要占用一定的存储单元空间(假设为x)
那么第i(从0计算)个元素的存储位置为
indexi = index0 + i*x 从这个公式可以得到此时存取操作时间性能为O(1)即常数性能,因为计算机只需要计算一次。
我们通常将存取操作具备常数性能的存储结构称为随机存储结构

顺序表类的定义

类的设计包括两部分:类定义和类实现。类定义给出类的成员变量和成员函数的定义,类实现给出成员函数的具体编码实现。
类的成员变量用来表示抽象数据类型的数据集合,类的成员函数用来表示抽象数据类型的操作集合

class SeqList       //SeqList是类名
{
protected:
	DataType *list;                           //数组元素的数据类型
	int maxSize;                              //最大元素个数
	int size;                                 //当前元素个数
public:                                       
	SeqList(int max=0);                       //构造函数
	~SeqList(void);                           //析构函数
	int Size(void) const;                      //取当前数据元素个数
	void Insert(const DataType& item,int i);  //插入
	DataType Delete(const int i);             //删除
	DataType GetData(int i) const;            //取数据元素
};

顺序表类的实现


SeqList::SeqList(int max)                   //构造函数
{
	maxSize = max;
	size = 0;
	list = new DataType[maxSize];
}

SeqList::~SeqList(void)                       //析构函数
{
	delete[]list;
}

int SeqList::Size(void) const        //取当前数据元素个数
{
	return size;
}

void SeqList::Insert(const DataType& item, int i)    //插入
//在指定位置i前插入一个数据元素item
{
	//从size-1至i逐个元素后移
	for (int j = size; j > i; j--) list[j] = list[j - 1];
	list[i] = item;                                   //在i位置插入item
	size++;                  //当前元素个数加1
}

DataType SeqList::Delete(const int i)      //删除
//删除指定位置i的数据元素,删除的元素由函数返回
{
	DataType x = list[i];
	//取到要删除的元素
//从i+1至size-1逐个元素前移
	for (int j = 1; j < size - 1; j++) list[j] = list[j + 1];
	size--;                                //当前元素个数减1
	return x;                              //返回删除的元素
}

DataType SeqList::GetData(int i) const    //取数据元素
//取位置i的数据元素,取到的数据元素由函数返回
{
	return list[i];                       //返回取到的元素
}

注意:
(1)类的成员变量通常设计成私有(private)访问权限,该类要被作为基类继承,所以设计成保护(protected)访问权限。 (2)构造函数要完成对象定义以及初始化赋值 (3)对于动态数组存储结构,析构函数要释放动态申请的内存空间 (4)插入成员函数应该考虑插入位置参数i的范围以及数组的存储空间是否已满

效率分析

时间效率:算法时间主要耗费在移动元素的操作上

  • 存取操作:O(1)
  • 插入和删除:O(n),因为插入或删除后,需要移动其余元素,有n中位置插入。

优缺点和适用场景

优点:算法简单,空间单元利用率高;
缺点:需要预先确定数据元素的最大个数,插入和删除时需要移动较多的数据元素。

线性表顺序存储结构比较适用于元素存取操作较多,增删操作较少的场景

顺序表类应用举例

编程实现如下任务:建立一个线性表,首先依次输入数据元素1,2,3,…,10,然后删除数据元素5,最后依次显示当前线性表中的数据元素。要求采用顺序表实现,假设该顺序表的数据元素个数在最坏情况下不会超过100个。

实现的方法,利用已设计好的抽象数据类型模块(存放在头文件名为SeqList.h中,通过 #include “SeqList.h” ),直接编写一个主函数实现。
程序如下:

#include <iostream>
#include <stdlib.h>

using namespace std;
typedef int DataType;           //定义具体问题元素的数据类型
#include "SeqList.h"            //包含顺序表类
int main()
{
	SeqList myList(100);        //定义顺序表类对象myList
	int n = 10;
	for (int i = 0; i < n; i++)     //在myList中顺序插入10个元素
		myList.Insert(i + 1, i);
	myList.Delete(4);           //删除myList中数据元素5
	for (int i = 0; i < myList.Size(); i++)
		cout << myList.GetData(i) << " ";
	system("pause");
	return 0;
}

注意,使用下列语句使得抽象数据类型实际上转为int类型
typedef int DataType;


0x03 线性表的链式表示和实现(单链表)


定义

一个或多个结点组合而成的数据结构称为链表。单链表中构成链表的结点只有一个指向直接后继结点的指针域。其结构特点:逻辑上相邻的数据元素在物理上不一定相邻。

结点一般由两部分构成:

  • 数据域:存储真正的数据元素
  • 指针域:存储下一个结点(直接后继)的地址(指针)
    头指针:是指向链表中第一个结点(或为头结点、或为首元结点)的指针。之后的每一个结点,其实就是上一个的后继指针指向的位置。链式存储时只要不是循环链表,就一定存在头指针。
    头结点:为了能更加方便地对链表进行操作,会在单链表的首元结点前附设一个结点,称为头结点,其数据域一般无意义(有时也会存链表长度等,头结点不计入表长度)。
    元结点是指链表中存储线性表第一个数据元素a0的结点。
    头指针与头结点:当不存在头结点时,头指针指向首个有真实数据的结点,当存在头结点时,头指针指向头结点,头结点的指针域指向首个有真实数据的结点。

存在头结点时:
图片2.png

不存在头结点时:
图片3.png

结点类的定义和实现

//List.h
#include<iostream>
template<class T>class List;        //前向引用声明
template<class T>
//Node类的定义和实现
class Node
{
public:
	friend class List<T>;                            //List类为友元类
	Node(Node<T>* Next = NULL) { next = Next; }       //构造函数一,构造头结点,没有data数据
	//构造函数二,构造其他结点,有data数据
	Node(T& Date, Node<T>* Next = 0)
	{
		date = Date;
		next = Next;
	}
	~Node() {}
private:
	T date;
	Node<T>* next;           //next指向下一个结点
};

单链表类的定义

这里实现的单链表其实是有序单链表,插入数据是先找到应该插入的位置,再进行插入,非有序的情况下的单链表的实现更加简单。

template<class T>
class List
{
private:
	Node<T>* head;                                    //头指针
	int size;                                         //数据元素的个数
public:
	List();                                           //构造函数
	~List();                                          //析构函数
	Node<T>* Index(int i);                            //定位
	void Insert(T& Data);                             //插入
	T Delete(int i);								  //删除数据并返回结果
	T GetData(int i) { return Index(i)->data; }       //获得数据
	//这里还可以根据需要添加Get size函数
};

单链表类的实现

实现是如果是带头结点的,那么构造函数要用new运算符动态申请一个头结点并由头指针指示,初始时当前数据元素个数为0。

析构函数要完成单链表中所有结点内存空间的释放。

插入的实现:首先找到第i个结点并由Index(i)指示,动态申请一个新的结点,其next指向第i个结点,然后修改i-1结点的指针域指向新结点。

//List类的实现
template<class T>
List<T>::List()
{
	head = new Node<T>();            //头结点指向一个Node类
	size = 0;                        //初始时数据元素个数为0
}
template<class T>
List<T>::~List()
{
	Node<T>* p;
	while (head != 0)                //while删除当前链表的第一个结点,直到head=0,没有结点
	{
		p = head;
		head = p->next;
		delete p;
	}
	size = 0;                        //数据元素个数为0
}
template<class T>
Node<T>* List<T>::Index(int i)
{              //定位,目的是找指向第i个数据元素的指针并返回
	if (i<-1 || i>(size - 1))                //i的范围为-1到size-1
	{
		cout << "错误的i值" << endl;
		exit(0);                             //错误的话结束
	}
	if (i == -1)return head;                 //i=-1时,指向头结点
	Node<T>* p = head->next;                 //p首先指向第0个数据元素结点
	int j = 0;
	while (p != 0 && j < i)                  //循环,找到指向第i个结点的指针
	{
		p = p->next;
		j++;
	}
	return p;                                //返回指向第i个结点的指针
}

template<class T>
void List<T>::Insert(T& Data)                //插入数据,这里的插入是按照大小顺序插入的
{
	int i = 0;                                                       //从0开始计数
	this->size++;                                                    //插入数据时该链表数据元素个数加一,这样定位时新加的元素如果在最后,则Index返回指针是空指针
	while (Index(i) != 0 && Index(i)->data <= Data) { i++; }         //找比data大的所在的结点的指针
	Node<T>* p = new Node<T>(Data, Index(i));                        //构造新的结点
	Index(i - 1)->next = p;                                          //新结点前面的结点的next指向新结点
}

template<class T>
T List<T>::Delete(int i)
{
	Node<T>* s, * p = Index(i - 1);       //p为指向第i-1个结点指针
	s = p->next;                            //s指向第i个结点
	p->next = p->next->next;                //第i个结点脱链
	T x = s->data;
	delete s;                             //释放第i个结点空间
	size--;                               //结点个数减1
	return x;                             //返回第i个结点的data域值
}

效率分析

时间复杂度:插入和删除操作时不需要移动数据元素,只需要比较数据元素。

  • 存取操作:假设需要获取第 i 个元素,则必须从第一个结点开始依次进行遍历,直到达到第 i 个结点。因此,对于单链表结构而言,其数据元素读取的时间复杂度为O(n)
  • 插入和删除操作:对其任意一个位置进行增删操作,因为需要先遍历找到目标元素,其时间复杂度为O(n)。

顺序表和单链表比较

只对一个元素进行增删操作时,两种结构并不存在优劣之分,而如果针对多个数据进行增删,那么单链表较为合适,因为后续的增删只是简单的赋值移动指针。
单链表的优点:不需要预先确定数据元素的最大个数,插入和删除操作不需要移动数据元素;
单链表的缺点:每个结点中要有一个指针域,因此空间单元利用率不高。而且单链表操作的算法也较复杂。
总结:对于存取频繁的场景,使用线性表较好,对于插入和删除频繁的场景,使用单链表一般较好。

顺序表类应用举例

编程实现如下任务:建立一个线性表,首先依次输入数据元素1,2,3,…,10,然后删除数据元素5,最后依次显示当前线性表中的数据元素。要求采用单链表实现。
结点类和单链表类的定义和实现代码存放在文件List.h中,通过直接编写一个主函数来实现。
程序如下:

#include <iostream>
using namespace std;
#include"List.h"
int main()
{
	List <int> myList;
	int s[] = { 1,2,3,4,5,6,7,8,9,10 }, n = 10;
	int temp;
	for (int i = 0; i < n; i++)
		myList.Insert(i);
	myList.Delete(4);
	for (int i = 0; i < 9; i++)
	{
		temp = myList.GetData(i);
		cout << temp << " ";
	}
	cout << endl;
	system("pause");
	return 0;
}

结果

0 1 2 3 5 6 7 8 9
请按任意键继续. . .

0x04 循环单链表和双向链表


循环单链表

实现:循环单链表是单链表的另一种形式,将单链表中的终端结点的指针域由空指针改为指向头结点,使整个单链表形成一个环,这种头尾相连的单循环链表,称为循环链表。循环链表不一定需要头结点。
单链表区别:主要差异在于循环的判断条件上,单链表判断条件为判断尾结点是否指向空,循环链表判断条件为判断结点是否指向头结点(头指针)
优点:从链尾到链头比较方便。

双向链表

双向链表是每个结点除后继指针域外还有一个前驱指针域,它有带头结点和不带头结点,循环和非循环结构,双向链表是解决查找前驱结点问题的有效途径。在单链表的每个结点中,再设置一个指向其前驱结点的指针域。
p->next->prior=p;p->prior->next=p


0x05 静态链表


在数组中增加一个(或两个)指针域用来存放下一个(或上一个)数据元素在数组中的下标,从而构成用数组构造的单链表。因为数组内存空间的申请方式是静态的,所以称为静态链表,增加的指针称做仿真指针。


0x06 其他

在上述顺序类的基础上,还可以通过设计完成相应功能的函数,来达到对顺序表中某些数据元素x的删除,将单链表原地排序等功能。具体设计要根据实际情况进行。


Ch3 栈和队列


0x01 栈的定义和特点


栈:限定仅在表尾(栈顶)进行插入和删除操作的线性表。又称为 后进先出(Last In First Out) 的线性表,简称 LIFO 结构。

  • 栈顶:把允许插入和删除的一端称为栈顶
  • 栈底:栈顶相对的另一端为栈底
  • 空栈:不含有任何数据元素的栈称为空栈
    常见操作集合:初始化堆栈S,入栈,出栈,取栈顶元素,判断堆栈是否为非空等。

0x02 栈的表示和实现


顺序栈

可以使用线性表的顺序存储结构(即数组)实现栈,将之称之为 顺序栈

结构如下:
顺序栈结构图.png
a0, a1, a2, a3, a4表示顺序堆栈中已存储的数据元素,stack表示存放数据元素的数组,MaxStackSize-1表示最大存储单元个数,top表示当前栈顶存储下标。

顺序栈的定义

//顺序栈类的定义SeqStack
class SeqStack
{
private:
	DataType data[MaxStackSize];      //顺序堆栈数组
	int top;                                            //栈顶位置指示器
public:
	SeqStack(void) { top = 0; }       //构造函数
	~SeqStack(void) {}            //析构函数
	void Push(const DataType item);      //入栈
	DataType Pop(void);                  //出栈
	DataType GetTop(void)const;      //取栈顶数据元素
	int NotEmpty(void)const { return(top != 0); };            //堆栈非空否
	
};

顺序栈的实现

void SeqStack::Push(const DataType item)       //入栈
//把元素item入栈;堆栈满时出错退出
{
	if (top == MaxStackSize)
	{
		cout << "堆栈已满!" << endl;
		exit(0);
	}
	data[top] = item;                       //先存储item
	top++;                                //然后top加1
}

DataType SeqStack::Pop()                       //出栈
//出栈并返回栈顶元素;堆栈空时出错退出
{
	if (top == 0)
	{
		cout << "堆栈已空!" << endl;
		exit(0);
	}
	top--;                             //top先减1
	return data[top];                 //然后取元素返回
}

DataType SeqStack::GetTop(void)const           //取栈顶数据元素
//取当前栈顶数据元素并返回
{
	if (top == 0)
	{
		cout << "堆栈空!" << endl;
		exit(0);
	}
	return data[top - 1];                        //返回当前栈顶元素
}

程序测试

#include <iostream>
using namespace std;

typedef int DataType;           //定义具体问题元素的数据类型
const int MaxStackSize = 100;     //定义问题要求的元素数目的最大值
#include"SeqStack.h"

int main()
{
	SeqStack myStack;       //构造函数无参数时,定义的对象后不带括号
	DataType test[] = { 1,3,5,7,9 };
	int n = 5;
	for (int i = 0; i < n; i++)
		myStack.Push(test[i]);
	while (myStack.NotEmpty())
		cout << myStack.Pop() << " ";
	cout << endl;
	system("pause");
	return 0;
}

结果如下:

9 7 5 3 1
请按任意键继续. . .

链栈

可以使用单链表结构实现栈,将之称之为 链栈
存储结构:以头指针为栈顶,在头指针处插入或删除。
结构如下:
链式栈结构图.png

链式栈结点类的定义和实现

template <class T> class LinStack;           //前视定义,否则友元无法定义
//结点类定义和实现	  
template <class T>                          //模板类型为T 			     
class StackNode
{									
	friend class LinStack <T>;                 //定义类LinStack<T>为友元	
private:  
	T data;                                    //数据元素	
	StackNode <T>* next;                        //指针
public:
	//构造函数1,用于构造头结点
	StackNode(StackNode <T>* ptrNext = NULL)
	{
		next = ptrNext;
	}
	//构造函数2,用于构造其他结点
	StackNode(const T& item, StackNode <T>* ptrNext = NULL)
	{
		data = item; 
		next = ptrNext;
	}
	~StackNode() {};
};

链式栈的定义

//链式堆栈类的定义
template <class T>                                             
class LinStack {
private:        				                                                        	
	   StackNode <T>* head;                   //头指针                  	 	
	   int size;                              //数据元素个数
public:                                                       	
	   LinStack(void);                        //构造函数			
	   ~LinStack(void);                        //析构函数
	   void Push(const T& item);              //入栈
	   T Pop(void);                           //出栈
	   T GetTop(void) const;                  //取栈顶元素
	   int NotEmpty(void) const;              //堆栈非空否
};

链式栈的实现


//链式堆栈类的实现
template <class T>
LinStack <T>::LinStack()                    //构造函数
{
	head = new StackNode <T>;                 //头指针指向头结点
	size = 0;                                 //size的初值为0
}


template <class T>
LinStack <T>::~LinStack(void)               //析构函数
//释放所有动态申请的结点空间
{
	StackNode <T>* p, * q;
	p = head;                                 //p指向头结点
	while (p != NULL)                          //循环释放结点空间
	{
		q = p;
		p = p->next;
		delete q;
	}
}

template <class T>
int LinStack <T>::NotEmpty(void) const      //堆栈非空否
{
	if (size != 0) return 1;
	else return 0;
}

template <class T>
void LinStack <T>::Push(const T& item)      //入栈
{
	//新结点newNode的data域值为item,next域值为head->next
	StackNode <T>* newNode = new StackNode <T>(item, head->next);
	head->next = newNode;                    //新结点插入栈顶
	size++;                                //元素个数加1
}

template <class T>
T LinStack <T>::Pop(void)                  //出栈
{
	if (size == 0) {
		cout << "堆栈已空无元素可删!" << endl;                            		exit(0);
	}
	StackNode <T>* p = head->next;       //p指向栈顶元素结点
	T data = p->data;
	head->next = head->next->next;       //原栈顶元素结点脱链
	delete p;                          //释放原栈顶结点空间
	size--;                            //结点个数减1
	return data;                   //返回原栈顶结点的data域值
}


template <class T>
T LinStack <T>::GetTop(void) const         //取栈顶元素
{
	return head->next->data;
}


程序测试

#include <iostream>
using namespace std;

typedef int DataType;           //定义具体问题元素的数据类型
//const int MaxStackSize = 100;     //定义问题要求的元素数目的最大值
//#include"SeqStack.h"
#include"LinStack.h"

int main()
{
	LinStack <int> myStack;       //构造函数无参数时,定义的对象后不带括号
	DataType test[] = { 1,3,4,7,5 };
	int n = 5;
	for (int i = 0; i < n; i++)
		myStack.Push(test[i]);
	while (myStack.NotEmpty())
		cout << myStack.Pop() << " ";
	cout << endl;
	system("pause");
	return 0;
}

结果如下:

5 7 4 3 1
请按任意键继续. . .

一般不会出现栈满情况;除非没有空间导致malloc分配失败。
链栈的入栈、出栈操作就是栈顶的插入与删除操作,修改指针即可完成。
采用链栈存储方式的优点是,可使多个栈共享空间;当栈中元素个数变化较大,且存在多个栈的情况下,链栈是栈的首选存储方式。

顺序栈和链栈的异同

顺序栈

需要事先确定一个固定的长度(数组长度)
可能存在内存空间浪费问题,但优势是存取时定位很方便。

链栈

要求每个元素都要配套一个指向下个结点的指针域。
增大了内存开销,但好处是栈的长度(几乎)无限。
如果栈的使用过程中元素变化有时很大有时很小,那么用链栈较好,否则使用顺序栈即可。

堆栈应用

括号匹配问题

假设一个算法表达式中包含圆括号、方括号和花括号三种类型的括号,编写一个判别表达式中括号是否正确配对的函数。

思路:用栈暂时存左括号,每次读取时如果是左括号则入栈,是右括号则判断栈是否是空的,是空的则说明右括号多,return;不是空,则判断是否是与栈顶匹配的右括号;字符最后全部读完后,判断栈中是否还有元素,有元素则说明有多余的左括号。
详细代码如下:

#include <iostream>
using namespace std;

typedef char DataType;           //定义具体问题元素的数据类型
const int MaxStackSize = 100;     //定义问题要求的元素数目的最大值
#include"SeqStack.h"
//#include"LinStack.h"

void ExpIsCorrect(char exp[], int n)
//判断有n个字符的字符串exp左右括号是否配对正确
{
	SeqStack myStack;        //定义顺序堆栈类对象myStack
	int i;
	for (i = 0; i < n; i++)
	{
		if ((exp[i] == '(') || (exp[i] == '[') || (exp[i] == '{'))
			myStack.Push(exp[i]);           //入栈
		else if (exp[i] == ')' && myStack.NotEmpty()
			&& myStack.GetTop() == '(')
			myStack.Pop();                  //出栈

		else if (exp[i] == ')' && myStack.NotEmpty()
			&& myStack.GetTop() != '(')
		{
			cout << "左、右括号配对次序不正确!" << endl;
			return;
		}
		else if (exp[i] == ']' && myStack.NotEmpty()
			&& myStack.GetTop() == '[')
			myStack.Pop();                  //出栈
		else if (exp[i] == ']' && myStack.NotEmpty()
			&& myStack.GetTop() != '[')
		{
			cout << "左、右括号配对次序不正确!" << endl;
			return;
		}

		else if (exp[i] == '}' && myStack.NotEmpty()
			&& myStack.GetTop() == '{')
			myStack.Pop();              //出栈
		else if (exp[i] == '}' && myStack.NotEmpty()
			&& myStack.GetTop() != '{')
		{
			cout << "左、右括号配对次序不正确!" << endl;
			return;
		}
		else if (((exp[i] == ')') || (exp[i] == ']') || (exp[i] == '{'))
			&& !myStack.NotEmpty())
		{
			cout << "右括号多于左括号!" << endl;
			return;
		}
	}
	if (myStack.NotEmpty())
		cout << "左括号多于右括号!" << endl;
	else
		cout << "左、右括号匹配正确!" << endl;
}

int main() {
	char a[] = "((())){}(][";
	char b[] = "((())){}{}()[";
	char c[] = "((())){}{})[]]";
	char d[] = "((())){}{}()[]";
	int aa = strlen(a), bb = strlen(b), cc = strlen(c), dd = strlen(d);
	cout << "((())){}(][  "; 
	ExpIsCorrect(a, aa);
	cout << "((())){}{}()[  "; 
	ExpIsCorrect(b, bb);
	cout << "((())){}{})[]]  "; 
	ExpIsCorrect(c, cc);
	cout << "((())){}{}()[]  "; 
	ExpIsCorrect(d, dd);


	system("pause");
	return 0;
}

结果如下:

((())){}(][  左、右括号配对次序不正确!
((())){}{}()[  左括号多于右括号!
((())){}{})[]]  右括号多于左括号!
((())){}{}()[]  左、右括号匹配正确!
请按任意键继续. . .

表达式计算问题

表达式计算是堆栈的一个典型利用,通常需要变换表达式的表示序列,如下图。
整个计算过程分为两步,一是中缀表达式转为后缀表达式,二是利用后缀表达式计算。

表达式转换

中缀表达式:A+(B-C/D)E
对应的后缀表达式:ABCD/-E
+
中缀表达式转为后缀表达式的算法步骤如下:
(1)设置一个堆栈,初始时将栈顶元素置为“#”。
(2)顺序读入中缀表达式,当读到的为操作数时就将其输出,并接着读下一个单词。
(3)令x1为当前栈顶运算符的变量,x2为当前扫描读到运算符的变量,当顺序从中缀表达式中读入的单词为运算符时就赋予x2,然后比较x1的优先级与x2的优先级,若x1的优先级高于x2的优先级,将x1退栈并作为后缀表达式的一个单词输出,然后接着比较新的栈顶运算符x1的优先级与x2的优先级。若x1的优先级低于x2的优先级,将x2进栈然后接着读下一个单词;若x1的优先级等于x2的优先级,且x1为(,x2为)时,将进行退栈操作,然后接着读下一个单词;若x1的优先级等于的x2优先级,且x2为#时,算法结束。

下面是一个中缀表达式变换成后缀表达式具体过程:
中缀表达式转为后缀表达式.png

计算后缀表达式的值

设置一个堆栈存放操作数,从左到右依次扫描后缀表达式,每读到一个操作数就将其进栈;每读到一个运算符就从栈顶取出两个操作数施以该运算符所代表的运算操作,并把该运算结果作为一个新的操作数入栈;此过程一直进行到后缀表达式读完,最后栈顶的操作数就是该后缀表达式的运算结果

这个后缀表达式可以通过字符串形式,也可以通过堆栈等来进行扫描,只是通过不同方式,需要进行的预处理操作不同。


0x03 队列的定义和特点


队列:限定仅在一端(队尾)进行插入操作,而在另一端(队头)进行删除操作的线性表。

  • 队头:允许删除的一端
  • 队尾:允许插入的一端
    常见操作集合:初始化队列,入队列,出队列,取队头数据元素,判断队列是否非空等。
    队列是一种特殊的线性表,因此也存在着顺序存储和链式存储两种存储方式。
  • 顺序队列
    顺序队列是顺序存储结构的队列。下面是顺序队列的一个示例
    顺序队列动态示意图.png

顺序队列存在“假溢出”问题:
顺序队列因多次入队列和出队列操作,出现的有存储空间但不能进行入队列操作的情况称之为假溢出。(因为实际还有空间)

解决顺序队列的假溢出问题:
四种方法:

  • 采用循环队列。(把队列所使用的存储空间构造成一个逻辑上首尾相连的循环队列。当rear和front达到MaxQueueSize-1后,再前进一个位置就自动到位置0。)
  • 按最大可能的进队操作次数设置顺序队列的最大元素个数
  • 修改出队算法,使每次出队列后都把队列中剩余数据元素向队头方向移动一个位置。
  • 修改入队算法,增加判断条件,当出现假溢出时,把队列中的数据元素向队头移动,然后完成入队操作。

循环队列的队空和队满判断问题:
虽然解决假溢出,但是又引出新的问题,即在循环队列中,空队特征是front=rear;队满时也会有front=rear,出现了二义性。解决方法如下三种:

  • 使用计数器

判队满:count>0&&rear==front
判队空:count==0

  • 加设标志位

判队满:tag==1&&rear==front
判队空:tag==0 && rear==front

  • 少用一个存储单元

判队满: front==(rear+1)%MaxQueueSize
判队空: rear==front

0x04 队列的表示和实现

顺序循环队列

类定义

采用设置计数器方法来判断队空状态和队满状态,类定义如下:
SeqQueue.h

class SeqQueue {
private:                                                                	
	DataType data[MaxQueueSize];       //顺序队列数组            
	
	int front;                         //队头指示器                 	
	int rear;                          //队尾指示器               	
	int count;                         //元素个数计数器                  
public:                                                             	
	SeqQueue(void)                     //构造函数	
	{front=rear=0;count=0;}                                	
	~SeqQueue(void){};                 //析构函数                         	
	void Append(const DataType& item); //入队列                  	
	DataType Delete(void);             //出队列                	
	DataType GetFront(void)const;      //取队头数据元素              	
	int NotEmpty(void)const            //非空否              	
	{return count!=0;}
};

类实现


void SeqQueue::Append(const DataType& item)  //入队列
//把数据元素item插入队列作为当前的新队尾
{
	if (count > 0 && front == rear)
	{
		cout << "队列已满!" << endl;
		exit(0);
	}
	data[rear] = item;                //把元素item加在队尾
	rear = (rear + 1) % MaxQueueSize;      //队尾指示器加1
	count++;                            //计数器加1
}

DataType SeqQueue::Delete(void)        //出队列
//把队头元素出队列,出队列元素由函数返回
{
	if (count == 0)
	{
		cout << "队列已空!" << endl;
		exit(0);
	}
	DataType temp = data[front];         //保存原队头元素
	front = (front + 1) % MaxQueueSize;    //队头指示器加1
	count--;                           //计数器减1
	return temp;                       //返回原队头元素
}

DataType SeqQueue::GetFront(void)const   //取队头数据元素
//取队头元素并由函数返回
{
	if (count == 0)
	{
		cout << "队列已空!" << endl;
		exit(0);
	}
	return data[Front];                  //返回队头元素
}

链式队列类

链式存储结构的队列。链式队列的队头指针指向队列的当前队头结点;队尾指针指在队列的当前队尾结点。不带头结点的链式队列如下:
链式队列结构图.png

结点类的实现

template <class T> class LinQueue;//前视定义,否则友元无法定义
template <class T>
class QueueNode
{
	friend class LinQueue <T>;    //定义类LinQueue<T>为友元
private:                                                          	
	QueueNode <T> *next;                 //指针                        	
	T data;                              //数据元素                 
public:                                  //构造函数	
	QueueNode(const %& item,QueueNode <T> *ptrNext=NULL):data(item), next(ptrNext){}		
	~QueueNode() {};                  //析构函数
};

注意这里QueueNode(const %& item,QueueNode <T> *ptrNext=NULL):data(item), next(ptrNext){}
直接利用构造函数的参数初始化表。

链式队列类的定义与实现:

定义

template <class T>                                         
class LinQueue {
private:                                                       	
	QueueNode <T>* front;                //队头指针	
	QueueNode <T> *rear;                 //队尾指针               	
	int count;                           //计数器                
public:                                                    	
	LinQueue(void);                      //构造函数	
	~LinQueue(void);                     //析构函数	
	void Append(const T& item);          //入队列                  	
	T Delete(void);                      //出队列                	
	T GetFront(void)const;               //取队头数据元素	
	int NotEmpty(void)const              //非空否	
	{return count!=0;}
};

实现,这里也是增加一个count域来计算当前的元素个数。


template <class T>
LinQueue <T>::LinQueue()                 //构造函数
{
	front = rear = NULL;                     //链式队列无头结点
	count = 0;                             //count的初值为0
}

template <class T>
LinQueue <T>::~LinQueue(void)         //析构函数
{
	QueueNode <T>* p, * q;
	p = front;                           //p指向第一个结点
	while (p != NULL)             //循环直至全部结点空间释放
	{
		q = p;
		p = p->next;
		delete q;
	}
	count = 0;                             //置为初始化值0
	front = rear = NULL;
}

template <class T>
void LinQueue <T>::Append(const T& item)  //入队列
//把数据元素item插入队列作为新队尾结点
{
	//构造新结点newNode,newNode的data域值为item,next域值为NULL
	QueueNode <T>* newNode = new QueueNode <T>(item, NULL);
	if (rear != NULL) rear->next = newNode;       //新结点链入
	rear = newNode;                //队尾指针指向新队尾结点
	//若队头指针原先为空则置为指向新结点
	if (front == NULL) front = newNode;
	count++;                                //计数器加1
}


template <class T>                                                          
T LinQueue <T>::Delete(void)               //出队列
					   //把队头结点删除并由函数返回                       
{                                                               	
	if(count==0)                                                  	
	{                                                                   
		cout<<"队列已空!"<<endl;                                     		
		exit(0);                                                	
	}
	QueueNode <T>* p = front->next;      //p指向新的队头结点
	T data = front->data;         //保存原队头结点的data域值
	delete front;                     //释放原队头结点空间
	front = p;                       //front指向新的对头结点
	count--;                              //计数器减1
	return data;                //返回原队头结点的data域值
}

template <class T>
T LinQueue <T>::GetFront(void)const       //取队头数据元素
{
	if (count == 0)
	{
		cout << "队列已空!" << endl;
		exit(0);
	}
	return front->data;
}

队列的应用

编写判断一个字符序列是否是回文的函数

思路:把字符数组中的字符逐个分别存入队列和堆栈,然后逐个出队列和退栈并比较出队列的字符和退栈的字符是否相等,若全部相等则该字符序列是回文,否则就不是回文。(仅仅作为一个应用案例,实际上判断是否是回文有更简单方法,比如直接前后循环比较即可)

void HuiWen(char str[]) {
	LinStack <char> myStack;                                 	
	LinQueue <char> myQueue;                                        	
	int n = strlen(str);                  //求字符串长度	
	for(int i=0;i<n;i++)                                         	
	{                                                                   
		myQueue.Append(str[i]);                                  		
		myStack.Push(str[i]);                               	
	}                      	
	while(myQueue.NotEmpty()&&myStack.NotEmpty())                 	
	{                                                                   
		if(myQueue.Delete()!=myStack.Pop())                            		
		{                                               			       
			cout<<"不是回文!"<<endl;  			              
			return;                                    		
		}                                                   	
	}                                                              
	cout<<"是回文!"<<endl;
}

0x05 其他

优先级队列

带有优先级的队列。
与一般队列的区别:优先级队列的出队列操作不是把队头元素出队列,而是把队列中优先级最高的元素出队列。其数据元素的结构体不仅包括数据元素,还包括元素的优先级。除出队列操作外的其他操作的实现方法与前边讨论的顺序队列操作的实现方法相同。
可以利用优先级队列,模仿操作系统的进程管理,可以按优先级高的先服务、优先级相同的先到先服务的原则管理。

Ch4 串


0x01 串


定义/基本概念

串(string)是由零个或多个字符组成的有限序列,又叫字符串。(是数据元素为单个字符的特殊线性表)。
串长:串中字符的个数(n≥0)。

空串:串中字符的个数为0时称为空串。

空白串:由一个或多个空格符组成的串。

子串:串S中任意个连续的字符序列叫做S的子串,S叫主串。

子串位置:子串的第一个字符在主串中的序号。

字符位置:字符在串中的序号。

串相等:串长度相等,且对应位置上字符相等。(即两个串中的字符序列一一对应相等。)

注意

  • 空白串与空串不同,空串是指长度为零的串;而空白串是指包含一个或多个空白字符' '(空格等)的字符串。
  • 串与字符不同,“a”和'a'是不一样的。

串与一般的线性表的异同:
串是一种特殊的线性表。
线性表数据元素可以是任意数据类型,而串的数据类型只允许是字符类型。

逻辑结构与线性表相似,但是不同的是串中针对的是多个数据元素(即子串。线性表更关注对单个元素的操作如查找、删除或插入一个元素。而串中更多的是查找子串位置,得到指定位置子串,替换子串等。

串的操作一般有:初始化串,赋值,求串长度,比较两个串,插入子串,删除子串,取子串,查找子串,替换子串等。
下面是C++语言中的串函数:
用C++处理字符串时,要调用标准库函数 #include<string.h>
串长度:int strlen(char *str)
串比较:int strcmp(char *str1,char *str2)
串拷贝:char * strcpy(char *str1,char *str2)
串连接:char * strcat(char *str1,char *str2)
字符定位:char *strchr(char *str,char ch)
子串查找: char *strstr(char *s1,char *s2)

应用

例如外国人的 姓和名前后互换。(比较简单)

存储结构

顺序存储结构

串的顺序存储结构是用一组地址连续的存储单元来存储串中的字符序列。一般是用定长数组来定义。
可以将实际的串长度值保存在数组0下标位置,也可以放在数组最后一个下标位置。有些语言在串后面加一个如\0一样的不计入串长度的结束标记符来表示串值的终结。
串的顺序存储结构按照内存分配不同可以还分为下面两种

  • 静态数组结构

用静态内存分配方法定义的数组。在运行时数组元素的个数是不可改变的,也被称为定长数组结构。

  • 动态数组结构

用动态内存分配方法定义的数组。

链式存储结构

与线性表相似,但是由于每个元素都是一个字符,如果每个结点存储一个字符,那么会存在很大的空间浪费,因此,一个结点可以考虑存放多个字符。如果最后一个结点未被占满,那么可以使用#或其他非串值字符补全。

所以可分为单字符结点链、块链

注意,串的链式存储结构除了在链接串与串操作时有一定的方便之处,总的来说不如顺序存储灵活,性能也不如顺序存储结构好。

串的模式匹配算法

串的查找操作也称做串的模式匹配操作,其中Brute-Force算法和KMP算法是两种最经常使用的顺序存储结构下的串的模式匹配算法

Brute-Force算法

将主串S的第一个字符和模式T的第1个字符比较,若相等,继续逐个比较后续字符;若不等,从主串S的下一字符起,重新与T第一个字符比较。
直到主串S的一个连续子串字符序列与模式T相等。返回值为S中与T匹配的子序列第一个字符的序号,即匹配成功。
否则,匹配失败,返回值 –1。
串匹配算法1.png

int String::Find Substr(const String& t, int start)const
{
	int i = start, j = 0, v;
	while(i < size && j < t.size)
	{
		if(str[i] == t.str[j])
		{i++;j++;}
		else
		{i = i-j+1;j = 0;}//主串后移1位继续与字串比较
	}
	if(j >= t.size-1) v = i-t.size+1;
	else v = -1;
	return v;
} 

BF 算法的时间复杂度:
若n为主串长度,m为子串长度,则最坏O(n*m)。

KMP算法(此算法较难理解)

在BruteForce算法的基础上的模式匹配的改进算法。KMP算法的特点主要是消除了Brute-Force算法的如下缺点: 主串下标i在若干个字符序列比较相等后,只要有一个字符比较不相等便需要把下标i的值回退。分两种情况分析Brute-Force

具体算法介绍可参看下列网址:
https://www.zhihu.com/question/21923021

总体而言KMP算法是比较巧妙的,而上述网址中的解释算是一种比较容易理解的方式。

在运行效率上:
对于KMP,由于主串比较位置i无须回退,所以比较次数仅仅是n,而计算next[j]所用的比较次数为m,所以总次数为O(n+m)。(另:next[]数组的值是可以提前算好的)

Ch5 数组


0x01 数组


基本概念

数组是n个相同数据类型的数据元素构成的占用一块地址连续的内存单元的有限序列。
数组中任意一个元素可以用该元素在数组中的位置来表示,数组元素的位置通常称作数组的下标

数组与线性表比较:
同:都是若干相同数据类型的数据元素构成的有限序列
异:

  • 数组占用连续内存空间,线性表不一定
  • 线性表的元素是逻辑意义上不可再分的元素,而数组中每个元素还可以是数组
  • 数组的操作主要是存取,与线性表的插入、删除操作不同。

线性结构(包括线性表、堆栈、队列、串)的顺序存储结构实际就是使用数组来存储。数组是其他数据结构实现顺序存储结构的基础,是软件设计中基础的数据结构。

数组的实现,在C++中采用行主序的存放方法,即行优先顺序。一个m×n的二维数组可以看成是m行的一维数组。

动态数组类

静态数组在定义时就给出元素个数,动态数组是在具体申请存储单元空间时才给出数组元素的个数。
C++语言建立动态数组的运算符是new,销毁动态数组的运算符是delete

类定义
template <class T>                          //类的定义
class Array
{
private:                                                           	
	T* arr;			              //数组
	int size;		              //数组个数
public:
	Array(int sz = 100);			//构造函数
	Array(const Array<T>& a);		//拷贝构造函数
	~Array(void);				//析构函数
	int Size(void)const;			//取数组元素个数
	void operator=(const Array<T>& a);	//赋值
	T& operator[](int i);			//下标
	void Resize(int sz);			//重置数组
};
类的实现

template <class T>
Array<T>::Array(int sz)      		//构造函数
{
	if (sz <= 0)
	{
		cout << "无效的数组元素个数" << endl;
			exit(0);
	}

	arr = new T[sz];								//申请内存空间	
	size = sz;										//置数组元素个数
}

template <class T>
Array<T>::Array(const Array<T>& a)		//拷贝构造函数
{
	arr = new T[a.size];							//申请内存空间

//数组元素赋值
	for (int i = 0; i < a.size; i++)
		arr[i] = a.arr[i];

	size = a.size;									//置数组元素个数
}

template <class T>
Array<T>::~Array(void)   	        	//析构函数
{
	delete[]arr;									//释放内存空间
}

template <class T>
int Array<T>::Size(void)const	       //取数组元素个数
{
	return size;
}

template <class T>
void Array<T>::operator=(const Array<T>& a)	//赋值运算符重载
{
	delete arr;										//释放原内存空间
	arr = new T[a.size];							//申请新内存空间

//数组元素赋值
	for (int i = 0; i < a.size; i++)
		arr[i] = a.arr[i];

	size = a.size;									//置数组元素个数
}

template <class T>
T& Array<T>:: operator[](int i)     	//下标运算符重载
{
	if (i < 0 || i > size - 1)
	{
		cout << "下标越界" << endl;
		exit(0);
	}
	return arr[i];
}

template <class T>
void Array<T>::Resize(int sz)		//重置数组
{
	if (sz <= 0) { cout << "无效的数组个数" << endl;		    		exit(0); }                                           	if (sz == size) return;					T* newArray = new T[sz];	   //申请新数组空间
	int n = (sz <= size) ? sz : size;        //原数组元素拷贝
	for (int i = 0; i < n; i++)						newArray[i] = arr[i];					delete[]arr;			 //释放原数组空间
	arr = newArray;		//新数组指针赋值
	size = sz;			//置新数组个数
}

测试程序

#include <iostream>
using namespace std;

#include"Array.h"

int main()
{
	Array<int> a(10);
	int n = 10;
	for (int i = 0; i < n; i++) a[i] = i + 1;
	cout << "a[6] = " << a[6] << endl;
	cout << "Size of a = " << a.Size() << endl;
	Array<int> b = a;
	cout << "b[6] = " << b[6] << endl;
	cout << "Size of b = " << b.Size() << endl;
	a.Resize(40);
	a[21] = 21;
	cout << "a[21] = " << a[21] << endl;
	cout << "Size of a = " << a.Size() << endl;
	Array<int> c(a);
	cout << "c[21] = " << c[21] << endl;
	cout << "Size of c = " << c.Size() << endl;
	system("pause");
	return 0;
}

结果如下:

a[6] = 7
Size of a = 10
b[6] = 7
Size of b = 10
a[21] = 21
Size of a = 40
c[21] = 21
Size of c = 40
请按任意键继续. . .

0x02 特殊矩阵

特殊矩阵指有许多值相同的元素或有许多零元素、且值相同的元素或零元素的分布有一定规律的矩阵。

几种特殊矩阵的压缩存储

  • n阶对称矩阵
    关于主对角线对称,所以可以两个对称元素共享存储空间,节省一半存储空间。主要实现方法是通过数学映射关系:
    n阶对称矩阵压缩.png
  • n阶三角矩阵
    以主对角线划分,n阶三角矩阵有n阶上三角矩阵(下三角元素均为0或其他常数)和n阶下三角矩阵两种。
    三角矩阵.png

与n阶对称矩阵类似,可以通过数学公式映射来达到共享存储空间的目的。

0x03 稀疏矩阵

稀疏矩阵:矩阵中非零元素的个数远远小于矩阵元素个数
稠密矩阵:一个不稀疏的矩阵

稀疏矩阵压缩存储方法:只存储稀疏矩阵中的非零元素。
实现方法:每个非零元素用一个三元组(i,j,aij)来表示,每个稀疏矩阵可用一个三元组线性表来表示。(其中i和j代表行和列,aij代表对应的元素值)。
稀疏矩阵的压缩存储结构主要有三元组顺序表和三元组链表两大类型
稀疏矩阵例子.png

 struct DataType
         {    int row;                      //行号
              int col;                      //列号
              ElemType value;               //元素值
         } ;

稀疏矩阵的三元组顺序表

一个稀疏矩阵的三元组线性表的存储结构如下图所示,其中右下角代表了矩阵的行数,列数和非零元素数。
稀疏矩阵三元组存储.png

三元组顺序表类的定义和实现与顺序表类似,只是继承了顺序表类,同时元素改为了上述结构体,增加了矩阵转置函数。

矩阵的转置运算是把矩阵中每个元素的行号值转为列号值,把列号值转为行号值。

原稀疏矩阵三元组顺序表按先行序后列序的次序存放,若要求转置后的稀疏矩阵三元组顺序表仍然按先行序后列序的次序存放。那么成员函数可以设计如下:

void SeqSpaMatrix::Transpose(SeqSpaMatrix& a)
{
    if(dNum == 0)	return;
	else
	{
		int i, j, k;
		i = 0;	             				//i为a.list[]的下标值
		for(k = 1; k <= cols; k++)			//k为原矩阵的列下标
		{
			for(j = 0; j < a.dNum; j++)	//j为list[]的下标值
			{
				if(list[j].col == k)	//寻找原矩阵中列下标最小值,对于所有的元素,依次判断列下标是否是当前最小值
				{
				a.list[i].row = list[j].col;           //列号转为行号
				a.list[i].col = list[j].row;	     //行号转为列号
				a.list[i].value = list[j].value;  //数组元素复制
			    i++;
				}
			}
		}
	}
}

稀疏矩阵的三元组链表

(1)三元组链表
用链表存储的三元组线性表。
稀疏矩阵三元组线性表的带头结点的三元组链表结构如图所示,其中头结点的行号域存储了稀疏矩阵的行数,列号域存储了稀疏矩阵的列数。
矩阵链表1.png

缺点是实现矩阵运算操作算法的时间复杂度高,因为算法中若要访问某行某列中的一个元素时,必须从头指针进入后逐个结点查找。由此提出了下面的三元组链表。

(2)行指针数组结构的三元组链表
把每行非零元素三元组组织成一个单链表,再设计一个指针类型的数组存储
所有单链表的头指针。

稀疏矩阵链表2.png

各单链表均不带头结点。由于每个单链表中的行号域数值均相同,所以单链表中省略了三元组的行号域,而把行号统一放在了指针数组的行号域中。

但是对于从某列进行查找不方便,因此又提出了三元组十字链表结构。

(3)三元组十字链表
把非零元素三元组按行和按列组织成单链表,这样稀疏矩阵中的每个非零元素三元组结点都将既在行单链表上,又在列单链表上,形成十字形状。
稀疏矩阵链表3.png

具体实现略。

Ch6 递归算法

0x01 递归

递归:若一个算法直接或间接的调用自己本身,则称这个算法是递归算法。
存在算法调用自己的情况:

  • 问题的定义是递归的(如阶乘函数)
  • 问题的解法存在自调用(如在一个有序数组中查找一个数据元素是否存在的折半查找算法)

例子1:阶乘函数

#include<iostream>
using namespace std;

long int Fact(int n)
{
	long int x;
	if (n < 0)                         //n < 0时阶乘无定义
	{
		cout<<"参数错!"<<endl;
		return -1;
	}
	if (n == 0) return 1;
	else {
		x = Fact(n - 1);	   //递归调用
		return n * x;
	}
}

主函数

int main()
{
	long int fn,fn2;
	fn = Fact(3);
	fn2 = Fact(5);
	cout << fn << " "<< fn2 << endl;
	system("pause");
	return 0;
}

结果

6 120
请按任意键继续. . .

例子2:有序数组中查找元素x是否存在的折半查找。

int BSearch(int a[], int x, int low, int high)
{
	int mid;
	if (low > high) return -1;     //查找不成功
	mid = (low + high) / 2;
	if (x == a[mid])	return mid;	//查找成功
	else if (x < a[mid])
		return BSearch(a, x, low, mid - 1);	//在下半区查找
	else
		return BSearch(a, x, mid + 1, high);	//在上半区查找
}

主函数

int main()
{
	int a[] = { 1, 3, 4, 5, 17, 18, 31, 33 };
	int x = 17;
	int bn;
	bn = BSearch(a, x, 0, 7);
	if (bn == -1) cout<<"x不在数组a中"<<endl;
	else cout<<"x在数组a的下标"<< bn <<"中"<<endl;
	system("pause");
	return 0;
}

结果

x在数组a的下标4中
请按任意键继续. . .

0x02 递归算法的设计方法

基本思想:对于一个较复杂的问题,原问题分解成若干个相对简单且类同的子问题,这样,原问题就可递推得到解。

适用于递归算法求解的问题的充分必要条件是:
(1)问题具有某种可借用的类同自身的子问题描述的性质;
(2)某一有限步的子问题(也称作本原问题)有直接的解存在;

当一个问题存在上述两个基本要素时,该问题的递归算法的设计方法是
(1)把对原问题的求解设计成包含有对子问题求解的形式。
(2)设计递归出口。

例3 汉诺塔问题。

汉诺塔问题的描述是:设有3根标号为A,B,C的柱子,在A柱上放着n个盘子,每一个都比下面的略小一点,要求把A柱上的盘子全部移到C柱上,移动的规则是:(1)一次只能移动一个盘子;(2)移动过程中大盘子不能放在小盘子上面;(3)在移动过程中盘子可以放在A,B,C的任意一个柱子上。 

基本思想:1个盘子的汉诺塔问题可直接移动。n个盘子的汉诺塔问题可递归表示为,首先把上边的n-1个盘子从A柱移到B柱,然后把最下边的一个盘子从A柱移到C柱,最后把移到B柱的n-1个盘子再移到C柱。

4个盘子汉诺塔问题的递归求解示意图如下所示。
汉诺塔问题的递归求解.png

函数如下:

void towers(int n, char fromPeg, char toPeg, char auxPeg)
{
	if (n == 1)		//递归出口
	{
		cout<<"move disk 1 from peg "<<fromPeg<<" to peg "<< toPeg<<endl;
		return;
	}
	//把n-1个圆盘从fromPeg借助toPeg移至auxPeg
	towers(n - 1, fromPeg, auxPeg, toPeg);
	//把圆盘n由fromPeg直接移至toPeg
	cout<< "move disk "<<n<<" from peg "<<fromPeg<<" to peg "<<toPeg<<endl;
	//把n-1个圆盘从auxPeg借助fromPeg移至toPeg
	towers(n - 1, auxPeg, toPeg, fromPeg);
}

主函数

int main()
{
    towers(4, 'A', 'C', 'B');
	system("pause");
	return 0;
}

结果

move disk 1 from peg A to peg B
move disk 2 from peg A to peg C
move disk 1 from peg B to peg C
move disk 3 from peg A to peg B
move disk 1 from peg C to peg A
move disk 2 from peg C to peg B
move disk 1 from peg A to peg B
move disk 4 from peg A to peg C
move disk 1 from peg B to peg C
move disk 2 from peg B to peg A
move disk 1 from peg C to peg A
move disk 3 from peg B to peg C
move disk 1 from peg A to peg B
move disk 2 from peg A to peg C
move disk 1 from peg B to peg C
请按任意键继续. . .

递归算法的执行过程是不断地自调用,直到到达递归出口才结束自调用过程;到达递归出口后,递归算法开始按最后调用的过程最先返回的次序返回;返回到最外层的调用语句时递归算法执行过程结束

0x03 递归过程和运行时栈

对于非递归函数,调用函数在调用被调用函数前,系统要保存以下两类信息

  • (1)调用函数的返回地址;
  • (2)调用函数的局部变量值。

当执行完被调用函数,返回调用函数前,系统首先要恢复调用函数的局部变量值,然后返回调用函数的返回地址
递归函数被调用时,系统要做的工作和非递归函数被调用时系统要做的工作在形式上类同,但保存信息的方法不同
递归函数被调用时,系统的运行时栈也要保存上述两类信息。每一层递归调用所需保存的信息构成运行时栈的一个工作记录,在每进入下一层递归调用时,系统就建立一个新的工作记录,并把这个工作记录进栈成为运行时栈新的栈顶;每返回一层递归调用,就退栈一个工作记录。因为栈顶的工作记录必定是当前正在运行的递归函数的工作记录,所以栈顶的工作记录也称为活动记录

0x04 递归算法的效率分析

以斐波那契数列为例子。
递归实现:

long Fib(int n)
{	if(n == 0 || n == 1) return n;      //递归出口
else return Fib(n-1) + Fib(n-2);             //递归调用
}

循环实现:

long Fib2(int n)
{	long int oneBack, twoBack, current;
	int i;
	if(n == 0 || n == 1) return n;
	else
	{	oneBack = 1;
		twoBack = 0;
		for(i = 2; i <= n; i++)
		{	current = oneBack + twoBack;
			twoBack = oneBack;
			oneBack = current;
		}
		return current;
	}
}

循环结构的Fib2(n)算法在计算第n项的斐波那契数列时保存了当前已经计算得到的第n-1项和第n-2项的斐波那契数列,因此其时间复杂度为O(n)
递归结构的Fib(n)算法在计算第n项的斐波那契数列时,必须首先计算第n-1项和第n-2项的斐波那契数列,而某次递归计算得出的斐波那契数列,如Fib(n-1)、Fib(n-2)等无法保存,下一次要用到时还需要重新递归计算,因此其时间复杂度为O(2的n次方)

0x05 递归算法到非递归算法的转换

低级程序设计语言(如汇编语言)一般不支持递归,所以需要采用问题的非递归结构算法。

一般来说,存在如下两种情况的非递归算法

(1)存在不借助堆栈的循环结构的非递归的解决方法,如阶乘计算问题、斐波那契数列的计算问题、折半查找问题等。这种情况,可以直接选用循环结构的算法。
(2)存在借助堆栈的循环结构的非递归算法,所有递归算法都可以借助堆栈转换成循环结构的非递归算法。(其实就是利用堆栈模拟递归)。两种转换方法:一种方法是借助堆栈,用非递归算法形式化模拟递归算法的执行过程;另一种方法是根据要求解问题的特点,设计借助堆栈的循环结构算法。

0x06 设计举例

例1

从一个有n个人的团体中抽出k (k≤n)个人组成一个委员会,计算共有多少种构成方法。

委员会.png

int Comm(int n, int k)
{
    if(n < 1 || k < 0 || k > n) return 0;	
	if(k == 0) return 1;	
	if(n == k) return 1;	
    return Comm(n-1, k-1) + Comm(n-1, k); 
}

例2 最大公约数

最大公约数.png

int Gcd(int n, int m)
{	if(n < 0 || m < 0) exit(0);
	if(m == 0) return n;
	else if(m > n) return Gcd(m, n);
	else return Gcd(m, n % m);
}

回溯法

回溯法是递归算法的一种特殊形式,回溯法的基本思想是:对一个包括有很多结点,每个结点有若干个搜索分支的问题,把原问题分解为对若干个子问题求解的算法。当搜索到某个结点、发现无法再继续搜索下去时,就让搜索过程回溯退到该结点的前一结点,继续搜索这个结点的其他尚未搜索过的分支。搜索一直持续到找到问题的解或者是全部搜索分支都搜索完。

Ch7 树和二叉树


0x01 树


树的定义

树是n个结点的有限集,当n=0时称为空树。
树其实是一种递归的实现,树的定义中还用到了树的概念。
在任意一个非空树中:
(1)有且仅有一个特定的结点:根结点(root)(根结点没有前驱结点)。
(2)当n>1时,其余结点可以分为m>0个互不相交的有限集,其中每一个集合本身又是一棵树,并且称为根的子树。
(3)根结点存在时,是唯一的,不可能同时存在多个根结点。
(4)子结点存在时,子树的个数没有限制,但它们一定互不相交。

一些术语
  • 结点:由数据元素和构造数据元素之间关系的指针组成
  • 结点的度:结点所拥有的子树的个数
  • 叶结点:度为0的结点,也称作终端结点
  • 分支结点:度不为0的结点
  • 孩子结点:树中一个结点的子树的根结点
  • 双亲结点:若树中某结点有孩子结点,则这个结点就称作它的孩子结点的双亲结点
  • 兄弟结点:具有相同的双亲结点的结点
  • 树的度:树中所有结点的度的最大值
  • 结点的层次:从根结点到树中某结点所经路径上的分支数
  • 树的深度:树中所有结点的层次的最大值
  • 无序树:树中任意一个结点的各孩子结点之间的次序构成无关紧要的树
  • 有序树:树中任意一个结点的各孩子结点有严格排列次序的树
  • 森林:m(m≥0)棵树的集合。
树的表示方法

直观表示法、形式化表示法,凹入表示法。

树的操作集合

创建树,撤销树,查找树中当前结点的双亲结点,左孩子结点,右兄弟结点,遍历树等。

树的存储结构

树的结点之间的逻辑关系主要有双亲-孩子关系,兄弟关系。因此,从结点之间的逻辑关系分,树的存储结构主要有:双亲表示法、孩子表示法、双亲孩子表示法和孩子兄弟表示法四种组合。

双亲表示法`L.png

孩子表示法.png

双亲孩子表示法.png

孩子兄弟表示法.png

0x02 二叉树

二叉树是n≥0个结点的有限集合,该集合可以为空集(空二叉树),或者由一个根结点和两颗互不相交的分别称为根结点的左子树和右子树的二叉树组成。
二叉树特点:

  • 每个结点最多只能有两颗子树
  • 左子树和右子树是有顺序的,不能颠倒
  • 即使某个结点只有一颗子树,也要区分左右子树。
二叉树基本形态
  • 空二叉树
  • 只有根结点
  • 只有左子树
  • 只有右子树
  • 左右子树都有
特殊的二叉树

满二叉树:
在一棵二叉树中,如果所有分支结点都存在左子树和右子树,并且所有叶子结点都在同一层上,这样的二叉树称为满二叉树。(一个叶子都不少)
完全二叉树:
如果一棵深度为k,有n个结点的二叉树中各结点能够与深度为k的顺序编号的满二叉树从1到n标号的结点相对应的二叉树称为完全二叉树。(只有最后一层结点不满,且结点全部集中在左侧,允许右边缺少连续若干个结点)
满二叉树.png
完全二叉树.png

满二叉树是完全二叉树的一个特例。

二叉树的操作集合

创建二叉树、撤销二叉树、左插入结点、右插入结点、左删除子树、右删除子树、遍历二叉树。


二叉树的性质

二叉树的五种特性:
1.第i层最多有2的i次方个结点(i>=0,即从1开始计数时)
2.深度为k的二叉树最多有2的k+1次方-1个结点。(k>=-1)
3.具有n个结点的完全二叉树的深度k为向下取整的log2(n+1) + 1
4.对有n个结点的完全二叉树中的结点从上到下,从左到右编号i(0≤i≤n),那么有:

  • 如果i=0,则结点i是二叉树的根,无双亲。如果i>0,则其双亲是结点(i-1)/2(“/”表示整除)。
  • 如果2i+1≥n,则结点无左孩子,否则左孩子是结点2i+1。
  • 如果2i+2≥n,则结点i无右孩子,否则其右孩子是结点2i+2。

5.(补充)对于任何一颗二叉树,如果其叶子结点数为n0,度(即子结点数)为2的结点数为n2,那么有n0=n2+1
证明:
对于分支数有B=n-1=n2*2+n1 =》n = n2*2+n1 + 1
对于结点数,又有n = n2+n1+n0
综上得到结论n0=n2+1

二叉树的存储结构
  • 顺序存储结构
    完全二叉树的结点可按从上至下和从左至右的次序存储在一维数组中,其结点之间的关系可由公式计算得到。
    完全二叉树顺序存储.png
    一般的非完全二叉树,则可以通过添加空结点方式,进而用顺序存储结构存储。
    非完全二叉树顺序存储.png

  • 链式存储结构

最常用的是二叉链,每个结点包含三个域,分别是数据域,左孩子指针域,右孩子指针域。(也分为带头结点和不带头结点)。

  • 仿真指针存储结构

用数组存储二叉树中的结点,数组中每个结点除数据元素域外,再增加仿真指针域用于仿真常规指针建立二叉树中结点之间的关系。如下图。
仿真指针1.png

仿真指针2.png

二叉树的四种遍历方式

若规定D,L,R分别代表“访问根结点”、“遍历根结点的左子树”和“遍历根结点的右子树”,根据遍历算法对访问根结点处理的位置有前序遍历,中序遍历,后序遍历。若二叉树为空,则算法结束。

  • 前序遍历(DLR):先根再左再右
  • 中序遍历(LDR):先左再跟再右
  • 后序遍历(LRD):先左再右再根

二叉树遍历.png

用二叉树表示算术表达式:

二叉树算术表达式.png

  • 层序遍历
    按二叉树的层序次序(即从根结点层至叶结点层),同一层中按先左子树再右子树的次序遍历二叉树。
    算法如下:
(1)初始化设置一个队列;
(2)把根结点指针入队列;
(3)当队列非空时,循环执行步骤(3.a)到步骤(3.c);
(3.a)出队列取得一个结点指针,访问该结点;
(3.b)若该结点的左子树非空,则将该结点的左子树指针入队列;
(3.c)若该结点的右子树非空,则将该结点的右子树指针入队列;
(4)结束。

注意:当对一个二叉树用一种特定的遍历方法来遍历时,其遍历序列一定是线性的,且是惟一的。
二叉树的遍历方法和二叉树的结构:
二叉树是非线性结构,每个结点会有零个、一个或两个孩子结点,一个二叉树的遍历序列不能决定一棵二叉树,但某些不同的遍历序列组合可以惟一确定一棵二叉树。例如给定一棵二叉树的前序遍历序列中序遍历序列可以惟一确定一棵二叉树的结构。
重要结论:若二叉树中各结点的值均不相同,则:由二叉树的前序序列和中序序列,或由其后序序列和中序序列均能唯一地确定一棵二叉树,但由前序序列和后序序列却不一定能唯一地确定一棵二叉树。

例子:
已知一棵二叉树的中序序列和后序序列分别是BDCEAFHG 和 DECBHGFA,请画出这棵二叉树。

①由后序遍历特征,根结点必在后序序列尾部(A);
②由中序遍历特征,根结点必在其中间,而且其左部必全部是左子树子孙(BDCE),其右部必全部是右子树子孙(FHG);
③继而,根据后序中的DECB子树可确定B为A的左孩子,根据HGF子串可确定F为A的右孩子;以此类推。

结果.png

二叉树遍历的实现
template <class T>
void PreOrder(BiTreeNode<T> *t, void Visit(T item))
//使用Visit(item)函数前序遍历二叉树t
{
	if(t != NULL)
	{
		Visit(t->data);
		PreOrder(t->Left(), Visit);
		PreOrder(t->Right(), Visit);
	}
}

emplate <class T>
void InOrder(BiTreeNode<T> *t, void Visit(T item))
//使用Visit(item)函数中序遍历二叉树t
{
	if(t != NULL)
	{
		InOrder(t->Left(), Visit);
		Visit(t->data);
		InOrder(t->Right(), Visit);
	}
}


template <class T>
void PostOrder(BiTreeNode<T> *t, void Visit(T item))
//使用Visit(item)函数后序遍历二叉树t
{
	if(t != NULL)
	{
		PostOrder(t->Left(), Visit);
		PostOrder(t->Right(), Visit);
		Visit(t->data);
	}
}

二叉树遍历的应用
(1)二叉树的撤销

在释放某个结点的存储空间前必须先释放该结点左孩子结点的存储空间和右孩子结点的存储空间,因此,二叉树撤消操作必须是后序遍历的具体应用。

template <class T>
void Destroy(BiTreeNode<T> *&root)
{
	if((root) != NULL && (root)->Left() != NULL)
		Destroy(root->Left());

	if((root) != NULL && (root)->Right() != NULL)
		Destroy(root->Right());

	cout << root->data << " ";	  //此语句只是为了方便测试
	delete root;
}

(2)二叉树的打印

把二叉树逆时针旋转900,按照二叉树的凹入表示法打印二叉树。可把此函数设计成递归函数。由于把二叉树逆时针旋转900后,在屏幕上方的首先是右子树,然后是根结点,最后是左子树,所以打印二叉树算法是一种特殊的中序遍历算法的应用。

template <class T>
void PrintBiTree(BiTreeNode<T>* &root, int level)
//二叉树root第level层结点数据域值的横向显示
{	  if(root != NULL)			//如果二叉树不空
	{    //二叉树root->Right()第level+1层结点数据域值的横向显示
		PrintBiTree(root->Right(), level+1);		
		if(level != 0)							{	//走过6*(level-1)个空格
			for(int i = 0; i < 6*(level-1); i++) cout<<" ";
			cout << "  ----";		//显示横线----
		}
		cout << root->data << endl;	//显示结点的数据域值
		//二叉树root->Left()第level+1层结点数据域值的横向显示
		PrintBiTree(root->Left(), level+1);
	}
} 

(3)查找数据元素

在bt为根结点指针的二叉树中查找数据元素x,若查找到数据元素x时返回该结点的指针;若查找不到数据元素x时返回空指针。
此时可以任意顺序遍历,这里设计为先序遍历函数。

template <class T>     
BiTreeNode<T>* Search(BiTreeNode<T>* t, T x) {
	BiTreeNode<T>* p;	
	if (t == NULL) return NULL;	//空二叉树时的查找失败出口	
	if(t->data == x) return t;		//查找成功出口	
	if(t->Left() != NULL)						
	{									
		p = Search(t->Left(), x);		//在左子树查找		               
		if(p != NULL) return p;	//查找成功时结束递归过程	
	}						
	if(t->Right() != NULL)						
	{									
		p = Search(t->Right(), x);	//在右子树查找				
		if(p != NULL) return p;	//查找成功时结束递归过程	
	}						
	return NULL;				//查找失败出口	                  
}
非递归的二叉树遍历算法

所有递归算法都可以借助堆栈转换成循环结构的非递归算法,通常有两种方法:一种方法是形式化模拟转换,另一种方法是根据要求解问题的特点设计借助堆栈的循环结构算法。
非递归的二叉树前序遍历算法如下(第二种方法实现前序遍历):

(1)初始化设置一个堆栈;
(2)把根结点指针入栈;
(3)当堆栈非空时,循环执行步骤(3.a)到步骤(3.c);
(3.a)出栈取得一个结点指针,访问该结点;
(3.b)若该结点的右子树非空,则将该结点的右子树指针入栈;
(3.c)若该结点的左子树非空,则将该结点的左子树指针入栈;
(4)结束。

一个简单练习:
一棵完全二叉树有5000个结点,可以计算出其叶结点的个数是()个?

n0=n2+1 => n2=n0-1
5000 = n0+n1+n2 = 2n0-1+n1
由于是完全二叉树
因此n1 = 0(无解) 或 n1 = 1(此时n0=2500)
所以结果是2500

0x03 线索二叉树


二叉树的遍历有两种情况,一种是一次性遍历;另一种是分步遍历。分步遍历是指在规定了一棵二叉树的遍历方法后,每次只访问当前结点的数据域值,然后使结点为当前结点的后继结点,直到到达二叉树的最后一个结点为止。分步遍历方法提供了对二叉树进行循环遍历操作的工具。

线索二叉树既可以从前向后分步遍历二叉树,也可以从后向前分步遍历二叉树。

当按某种规则遍历二叉树时,保存遍历时得到的结点的后继结点信息和前驱结点信息的最常用的方法是建立线索二叉树。

对二叉链存储结构的二叉树分析可知,在有n个结点的二叉树中必定存在n+1个空链域。

线索二叉树有如下规定:当某结点的左指针为空时,令该指针指向按某种方法遍历二叉树时得到的该结点的前驱结点;当某结点的右指针为空时,令该指针指向按某种方法遍历二叉树时得到的该结点的后继结点。仅仅这样做会使我们不能区分左指针指向的结点到底是左孩子结点还是前驱结点,右指针指向的结点到底是右孩子结点还是后继结点。因此我们再在结点中增加两个线索标志位来区分这两种情况。线索标志位定义如下:
线索二叉树.png

结点中指向前驱结点和后继结点的指针称为线索(本质还是指针)。在二叉树的结点上加上线索的二叉树称作线索二叉树。对二叉树以某种方法(如前序、中序或后序方法)遍历使其变为线索二叉树的过程称作按该方法对二叉树进行的线索化

线索二叉树2.png

如上图,
前序遍历:ABDGCEF B的后继是D
中序遍历:DGBAECF B的后继是A
后序遍历:GDBEFCA B的后继是E
从图中可以看出B的后继指向的结点是不同的。


0x04 哈夫曼树


哈夫曼树

从A结点到B结点所经过的分支序列叫做从A结点到B结点的路径
从A结点到B结点所经过的分支个数叫做从A结点到B结点的路径长度
从二叉树的根结点到二叉树中所有叶结点的路径长度之和称作该二叉树的路径长度

设二叉树有n个带权值的叶结点,定义从二叉树的根结点到二叉树中所有叶结点的路径长度与相应叶结点权值的乘积之和称作该二叉树的带权路径长度(WPL)
WPL.png
其中,wi为第i个叶结点的权值,li为从根结点到第i个叶结点的路径长度。
二叉树带权路径长度.png

具有最小带权路径长度的二叉树称作哈夫曼(Huffman)树(或称最优二叉树)。要使一棵二叉树的带权路径长度WPL值最小,必须使权值越大的叶结点越靠近根结点。哈夫曼树构造算法为:

(1)由给定的n个权值{w1,w2,…,wn}构造n棵只有根结点的二叉树,从而得到一个二叉树森林F={T1,T2,…,Tn}。
(2)在二叉树森林F中选取根结点的权值最小和次小的两棵二叉树作为新的二叉树的左右子树构造新的二叉树,新的二叉树的根结点权值为左右子树根结点权值之和。
(3)在二叉树森林F中删除作为新二叉树左右子树的两棵二叉树,将新二叉树加入到二叉树森林F中。
(4)重复步骤(2)和(3),当二叉树森林F中只剩下一棵二叉树时,这棵二叉树就是所构造的哈夫曼树。

可参看下列链接:
https://baijiahao.baidu.com/s?id=1663514710675419737&wfr=spider&for=pc

哈夫曼树应用

哈夫曼编码问题

将传送的文字转换为二进制字符0和1组成的二进制串的过程为编码。
哈夫曼树可用于构造代码总长度最短的编码方案
具体构造方法如下:

设需要编码的字符集合为{d1,d2,…,dn},
各个字符在电文中出现的次数集合为{w1,w2,…,wn},
以d1,d2,…,dn作为叶结点,
以w1,w2,…,wn作为各叶结点的权值
构造一棵二叉树,
规定哈夫曼树中的左分支为0,
右分支为1,
则从根结点到每个叶结点所经过的分支
对应的0和1组成的序列
便为该结点对应字符的编码。
代码总长度最短的不等长编码
称之为哈夫曼编码。

哈夫曼编码.png


0x05 数与二叉树的转换


树转换为二叉树

(1)树中所有相同双亲结点的兄弟结点之间加一条连线。
(2)对树中不是双亲结点第一个孩子的结点,只保留新添加的该结点与左兄弟结点之间的连线,删去该结点与双亲结点之间的连线。
(3)整理所有保留的和添加的连线,使每个结点的第一个孩子结点连线位于左孩子指针位置,使每个结点的右兄弟结点连线位于右孩子指针位置。

树转换为二叉树.png

二叉树还原为树

(1)若某结点是其双亲结点的左孩子,则把该结点的右孩子、右孩子的右孩子……都与该结点的双亲结点用线连起来。
(2)删除原二叉树中所有双亲结点与右孩子结点的连线。
(3)整理所有保留的和添加的连线,使每个结点的所有孩子结点位于相同层次高度。

二叉树还原为树.png

0x06 树的遍历

树的遍历算法主要有先根遍历算法后根遍历算法两种。

树的先根遍历递归算法为:

(1)访问根结点;
(2)按照从左到右的次序先根遍历根结点的每一棵子树。

注意:树的先根遍历序列一定和该树转换的二叉树的先序遍历序列相同。

树的后根遍历递归算法为:

(1)按照从左到右的次序后根遍历根结点的每一棵子树;
(2)访问根结点。

注意:树的后根遍历序列一定和该树转换的二叉树的中序遍历序列相同。

Ch8 图


0x01 图的基本概念和术语


是由顶点集合及顶点间的关系集合组成的一种数据结构。

一些基本术语

(1)顶点和边:图中的结点称作顶点,图中的第i个顶点记做vi。两个顶点vi和vj相关联称作顶点vi和vj之间有一条边,图中的第k条边记做ek,ek =(vi,vj)或<vi,vj>。
(2)有向图和无向图:在有向图中,顶点对<x ,y>是有序的,顶点对<x,y>称为从顶点x到顶点y的一条有向边,有向图中的边也称作;在无向图中,顶点对(x,y)是无序的,顶点对(x,y)称为与顶点x和顶点y相关联的一条边,即无向图中(x,y)和(y,x)其实是一条边。
(3)完全图:在有n个顶点的无向图中,若有n(n-1)/2条边,即任意两个顶点之间有且只有一条边,则称此图为无向完全图;在有n个顶点的有向图中,若有n(n-1)条边,即任意两个顶点之间有且只有方向相反的两条边,则称此图为有向完全图
(4)邻接顶点:在无向图G中,若(u,v)是E(G)中的一条边,则称u和v互为邻接顶点,并称边(u,v)依附于顶点u和v;在有向图G中,若<u,v>是E(G)中的一条边,则称顶点u邻接到顶点v,顶点v邻接自顶点u,并称边<u,v>和顶点u和顶点v相关联。
(5)顶点的度:顶点v的度是与它相关联的边的条数,记作TD(v)。
(6)路径:在图G=(V,E)中,若从顶点vi出发有一组边使可到达顶点vj,则称顶点vi到顶点vj的顶点序列为从顶点vi到顶点vj的路径。
(7):有些图的边附带有数据信息,这些附带的数据信息称为权。带权的图也称作网络或网。
(8)路径长度:对于不带权的图,一条路径的路径长度是指该路径上的边的条数;对于带权的图,一条路径的路径长度是指该路径上各个边权值的总和。
(9)子图:某个图的边和顶点都包含于另一个图,这个图就是另一个图的子图。
(10)连通图强连通图:在无向图中,若从顶点vi到顶点vj有路径,则称顶点vi和顶点vj是连通的。如果图中任意一对顶点都是连通的,则称该图是连通图。
在有向图中,若对于任意一对顶点vi和顶点vj(vi≠vj)都存在路径,则称图G是强连通图。
(11)生成树:一个连通图的最小连通子图称作该图的生成树。有n个顶点的连通图的生成树有n个顶点和n-1条边。
(12)简单路径和回路:若路径上各顶点v1,v2,…,vm,互不重复,则称这样的路径为简单路径;若路径上第一个顶点v1与最后一个顶点vm重合,则称这样的路径为回路或环 。

图的操作集合

初始化、插入顶点、插入边、删除边、删除顶点、第一个邻接节点、下一个邻接节点、遍历。


0x02 图的存储结构


主要有邻接矩阵邻接表两种。

邻接矩阵

邻接矩阵中元素aij=1表示存在vi到vj的边。下面是图邻接矩阵例子。
图邻接矩阵.png
对于带权图,aij的值可以为权重,其他为∞。

图邻接表存储结构

当图的边数少于顶点个数且顶点个数值较大时,图的邻居矩阵就成了稀疏矩阵的存储问题,此时,使用邻接表可能更为有效。

图的邻接表.png
数组的data域存储图的顶点信息,sorce域存储该顶点在数组中的下标序号,adj域为该顶点的邻接顶点单链表的头指针。第i行单链表中的dest域存储所有起始顶点为vi的邻接顶点vj在数组中的下标序号,next域为单链表中下一个邻接顶点的指针域。如果是带权图,单链表中需再增加cost域,用来存储边<vi,vj>的权值wij。

具体的实现可参看书上伪代码或者源码。


0x03 图的遍历


定义:从已给的连通图中某一顶点出发,沿着一些边访遍图中所有的顶点,且使每个顶点仅被访问一次,就叫做图的遍历,它是图的基本运算
遍历实质:找每个顶点的邻接点的过程。
图的特点:图中可能存在回路,且图的任一顶点都可能与其它顶点相通,在访问完某个顶点之后可能会沿着某些边又回到了曾经访问过的顶点。

如何避免重复访问?
解决思路:设置辅助数组 visited [n ],用来标记每个被访问过的顶点。
初始状态为0
i 被访问,改 visited [i]为1,防止被多次访问

常用的遍历方法有深度优先搜索和广度优先搜索。

深度优先搜索DFS

基本思想是模仿树的先根遍历过程
简单归纳:

-访问起始点v;
-若v的第1个邻接点没访问过,深度遍历此邻接点;
-若当前邻接点已访问过,再找v的第2个邻接点重新遍历。

具体实现时可以建立辅助数组,标识某个邻接点是否被访问过。
DFS.png
若不给定存储结构,深度优先遍历的序列不唯一。因为哪个顶点是第一邻接点未确定。给定存储结构后,深度优先遍历的结果是唯一的。

广度优先搜索BFS

通图的广度优先遍历算法为:

图的广度优先遍历算法是一个分层搜索的过程,需要一个队列以保持访问过的顶点的顺序,以便按访问过的顶点的顺序来访问这些顶点的邻接顶点。连通图的广度优先遍历算法为:
(1)访问初始顶点v并标记顶点v为已访问;
(2)顶点v入队列;
(3)当队列非空时则继续执行,否则算法结束;
(4)出队列取得队头顶点u;
(5)查找顶点u的第一个邻接顶点w;
(6)若顶点u的邻接顶点w不存在,则转到步骤(3),否则循环执行,
(6.1)若顶点w尚未被访问则访问顶点w并标记顶点w为已访问;
(6.2)顶点w入队列;
(6.3)查找顶点u的w邻接顶点后的下一个邻接顶点w,转到步骤(6)。

非连通图的遍历

对于非连通图,从图的任意一个顶点开始深度或广度优先遍历并不能访问图中的所有顶点。只能访问和初始顶点连通的所有顶点
但是,每一个顶点都作为一次初始顶点进行深度优先遍历或广度优先遍历,并根据顶点的访问标记来判断是否需要访问该顶点,就一定可以访问非连通图中的所有顶点。

深度优先遍历代码实现(重要)

连通图
邻接矩阵存储结构图类的深度优先遍历成员函数如下:
void AdjMWGraph::DepthFirstSearch(const int v, int visited[], 
void Visit(VerT item))
//连通图G以v为初始顶点序号、访问操作为Visit()的深度优先遍历
//数组visited标记了相应顶点是否已访问过,0表示未访问,1表示已访问
{
	Visit(GetValue(v));			//访问该顶点
	visited[v] = 1;				//置已访问标记

	int w = GetFirstNeighbor(v);		//取第一个邻接顶点
	while(w != -1)				//当邻接顶点存在时循环
	{
		if(! visited[w]) 
DepthFirstSearch(w, visited, Visit);                //递归
		w = GetNextNeighbor(v, w);	//取下一个邻接顶点
	}
}
非连通图
void AdjMWGraph::DepthFirstSearch(void Visit(VerT item))
//非连通图G访问操作为Visit()的深度优先遍历
{
	int *visited = new int[NumOfVertices()];

	for(int i = 0; i < NumOfVertices(); i++) 
visited[i] = 0;								//初始化访问标记

	for(i = 0; i < NumOfVertices(); i++)
		if(! visited[i]) 
DepthFirstSearch(i, visited, Visit);	//深度优先遍历

	delete []visited;
}

广度优先遍历代码实现(重要)

连通图BFS
#include "SeqQueue.h"		//包含静态数组结构的顺序队列类
void AdjMWGraph::BroadFirstSearch(const int v, int visited[], 
void Visit(VerT item))
//连通图G以v为初始顶点序号、访问操作为Visit()的广度优先遍历
//数组visited标记了相应顶点是否已访问过,0表示未访问,1表示已访问
{
	VerT u, w;
	SeqQueue queue;				//定义队列

	Visit(GetValue(v));				//访问该顶点
	visited[v] = 1;					//置已访问标记

	queue.Append(v);				//顶点v入队列
	while(queue.NotEmpty())			//队列非空时循环
	{
		u = queue.Delete();			//出队列
		w = GetFirstNeighbor(u);         //取顶点u的第一个邻接顶点
		                             while(w != -1)			//邻接顶点存在时
		{
		if(!visited[w])		//若该顶点没有访问过
		{
		 	Visit(GetValue(w));	//访问该顶点
			visited[w] = 1;		//置已访问标记
			queue.Append(w);	//顶点w入队列
			}
			//取顶点u的邻接顶点w的下一个邻接顶点
			w = GetNextNeighbor(u, w);
		}
	}
}
非连通图BFS
void AdjMWGraph::BroadFirstSearch(void Visit(VerT item))
//非连通图G访问操作为Visit()的广度优先遍历
{
	int *visited = new int[NumOfVertices()];

	for(int i = 0; i < NumOfVertices(); i++) 
visited[i] = 0;
	for(i = 0; i < NumOfVertices(); i++)
		if(!visited[i]) 
BroadFirstSearch(i, visited, Visit);

	delete []visited;
}

0x04 图的应用-最小生成树


基本概念

一个有n个顶点的连通图的生成树是原图的极小连通子图,它包含原图中的所有n个顶点,并且有保持图连通的最少的边。
注意:

(1)若在生成树中删除一条边就会使该生成树因变成非连通图而不再满足生成树的定义;
(2)若在生成树中增加一条边就会使该生成树中因存在回路而不再满足生成树的定义;
(3)一个连通图的生成树可能有许多;
(4)有n个顶点的无向连通图,无论它的生成树的形状如何,一定有且只有n-1条边。 

下面是无向图和它的几个不同的生成树。
生成树.png

如果无向连通图是一个带权图,那么它的所有生成树中必有一棵边的权值总和最小的生成树,我们称这棵生成树为最小代价生成树,简称最小生成树

典型构造方法有Prim算法和Kruskal算法(普利姆算法和克鲁斯卡尔算法)。

Prim算法

假设G=(V,E)为一个带权图,其中V为带权图中顶点的集合,E为带权图中边的权值集合。设置两个新的集合U和T,其中U用于存放带权图G的最小生成树的顶点的集合,T用于存放带权图G的最小生成树的权值的集合。普里姆算法思想是:令集合U的初值为U={u0}(即假设构造最小生成树时从顶点u0开始),集合T的初值为T={}。从所有顶点u∈U和顶点v∈V-U的带权边中选出具有最小权值的边(u,v),将顶点v加入集合U中,将边(u,v) 加入集合T中。如此不断重复,当U=V时则最小生成树构造完毕。此时集合U中存放着最小生成树顶点的集合,集合T中存放着最小生成树边的权值集合。

总结:每次挑与已有顶点相邻的有"最小"权值的边。
下面是从A开始的普利姆算法。
普利姆算法.png

具体算法略,时间复杂度O(n平方)

Kruskal算法

按照带权图中边的权值的递增顺序构造最小生成树。(如果这条边的两个顶点已经在生成树T中,则将此边舍去。
克鲁斯卡尔.png


0x05 图的应用-最短路径


路径长度:一条路径上所经过的边的数目。
带权路径长度:路径上所经过边的权值之和。
最短路径:(带权)路径长度(值)最小的那条路径。
最短路径长度最短距离:最短(带权)路径长度

迪克斯特拉算法

按路径长度递增的顺序逐步产生最短路径
狄克斯特拉算法的思想是:
设置两个顶点的集合S和T,集合S中存放已找到最短路径的顶点,集合T中存放当前还未找到最短路径的顶点。初始状态时,集合S中只包含源点,设为v0,然后从集合T中选择到源点v0路径长度最短的顶点u加入到集合S中,集合S中每加入一个新的顶点u都要修改源点v0到集合T中剩余顶点的当前最短路径长度值,集合T中各顶点的新的当前最短路径长度值,为原来的当前最短路径长度值与从源点过顶点u到达该顶点的路径长度中的较小者。此过程不断重复,直到集合T中的顶点全部加入到集合S 中为止。

迪克斯特拉.png

迪克斯特拉函数实现

函数共有4个参数,其中2个为输入参数,分别为带权图G和源点序号v0;2个为输出参数,分别为distance[]和path[],distance[]用来存放得到的从源点v0到其余各顶点的最短距离数值,path[]用来存放得到的从源点v0到其余各顶点的最短路径上到达目标顶点的前一顶点下标。

void Dijkstra(AdjMWGraph &G, int v0, int distance[], int path[])
//带权图G从下标v0顶点到其他顶点的最短距离distance
//和相应的目标顶点的前一顶点下标path
{
	int n = G.NumOfVertices();
	int *s = new int[n];			// s用来存放n个顶点的标记
	int minDis, i, j, u;
     //初始化 
	for(i = 0; i < n; i ++)								
	{
		distance[i] = G.GetWeight(v0, i);
		s[i] = 0;				    //初始均标记为0
		if(i != v0 && distance[i] < MaxWeight) 
                path[i] = v0;			   //初始的目标顶点的前一顶点均为v0
		else path[i] = -1;
	}
	s[v0] = 1;       		                 //标记顶点v0已从集合T加入到集合S中 

	//在当前还未找到最短路径的顶点集中选取具有最短距离的顶点u
	for(i = 1; i < n; i ++)
	{
		minDis = MaxWeight;
		for(j = 0; j < n; j ++)
			if(s[j] == 0 && distance[j] < minDis)
			{
				u = j;
				minDis = distance[j];
			}
                             //当已不存在路径时算法结束;此语句对非连通图是必须的
		if(minDis == MaxWeight) return;

		s[u] = 1;     		//标记顶点u已从集合T加入到集合S中

		//修改从v0到其他顶点的最短距离和最短路径
		for(j = 0; j < n; j++)
			if(s[j] == 0 && G.GetWeight(u, j) < MaxWeight && 
				distance[u] + G.GetWeight(u, j) < distance[j])
			{
			//顶点v0经顶点u到其他顶点的最短距离和最短路径
				distance[j] = distance[u] + G.GetWeight(u, j);
				path[j] = u;
			}
	}
}

每对顶点之间的最短路径

带权有向图,每对顶点之间的最短路径可通过调用狄克斯特拉算法实现。
具体方法是:每次以不同的顶点作为源点,调用狄克斯特拉算法求出从该源点到其余顶点的最短路径。重复n次就可求出每对顶点之间的最短路径。由于狄克斯特拉算法的时间复杂度为O(n的2次方),所以这种算法的时间复杂度为O(n的3次方)。

弗洛伊德算法的思想是:设矩阵cost用来存放带权有向图G的权值,即矩阵元素cost[i][j]中存放着下标为i的顶点到下标为j的顶点之间的权值,可以通过递推构造一个矩阵序列A0,A1,A2,……,AN来求每对顶点之间的最短路径。
具体算法实现略,可参考书上内容。


0x06 拓扑排序、关键路径(略)


Ch9 排序


0x01 基本概念


排序是对数据元素序列建立某种有序排列的过程,是把一个数据元素序列整理成按关键字递增(或递减)排列的过程。
关键字是要排序的数据元素集合中的一个域,排序是以关键字为基准进行的。
主关键字:数据元素值不同时该关键字的值也一定(数据库主键)不同,是能够惟一区分各个不同数据元素的关键字;不满足主关键字定义的关键字称为次关键字。
内部排序是把待排数据元素全部调入内存中进行的排序。
外部排序是因数量太大,把数据元素分批导入内存,排好序后再分批导出到磁盘和磁带外存介质上的排序方法。

比较排序算法优劣的标准:
(1)时间复杂度:它主要是分析记录关键字的比较次数和记录的 移动次数
(2)空间复杂度 :算法中使用的内存辅助空间的多少
(3)稳定性:若两个记录A和B的关键字值相等,但排序后A、B的 先后次序保持不变,则称这种排序算法是稳定的


0x02 插入排序


插入排序的基本思想是:每步将一个待排序的对象,按其关键码大小,插入到前面已经排好序的一组对象的适当位置上,直到对象全部插入为止。常见有直接插入排序希尔排序两种。

直接插入排序

顺序地把待排序的数据元素按其关键字值的大小插入到已排序数据元素子集合的适当位置。

void InsertSort (DataType a[], int n)
//用直接插入法对a[0]--a[n-1]排序
{
	int i, j;
	DataType temp;
	for(i=0;  i<n-1; i++)
	{
		temp = a[i+1];
		j = i;
		while(j > -1 && temp.key <= a[j].key)
		{
			a[j+1] = a[j];
			j--;
		}
		a[j+1] = temp;
	}
}
(1)时间效率: 因为在最坏情况下,所有元素的比较次数总和为(0+1+…+n-1)→O(n2)。其他情况下也要考虑移动元素的次数。 故时间复杂度为O(n2) 
(2)空间效率:仅占用1个缓冲单元——O(1)
(3)算法的稳定性:稳定

直接插入.png

希尔排序(缩小增量排序)

(1)基本思想:把整个待排序的数据元素分成若干个小组,对同一小组内的数据元素用直接插入法排序;小组的个数逐次缩小,当完成了所有数据元素都在一个组内的排序后排序过程结束。
(2)技巧:小组的构成不是简单地“逐段分割”,而是将相隔某个增量dk的记录组成一个小组,让增量dk逐趟缩短(例如依次取5,3,1),直到dk=1为止。
(3)优点:让关键字值小的元素能很快前移,且序列若基本有序时,再用直接插入排序处理,时间效率会高很多。

算法如下:
void ShellSort (DataType a[], int n, int d[], int numOfD)
//用希尔排序法对元素a[0]--a[n-1]排序,d[0]--d[numOfD-1]为希尔增量值
{		int i, j, k, m, span;
		DataType temp;

		for(m = 0; m < numOfD; m++)	       //共numOfD次循环
		{	span = d[m];		      //取本次的增量值
			for(k = 0; k < span; k++)	      //共span个小组
			{
		//组内是直接插入排序,区别是每次不是增1而是增span
				for(i = k; i < n-span; i = i+span)						{	temp = a[i+span];
					j = i;
					while(j > -1 && temp.key <= a[j].key)
					{	a[j+span] = a[j];
						j = j-span;
					}
					a[j+span] = temp;
				}
			}
		}
}

一个示例如下:
希尔排序.png


0x03 选择排序


基本思想:每次从待排序的数据元素集合中选取关键字最小(或最大)的数据元素放到数据元素集合的最前(或最后),数据元素集合不断缩小,当数据元素集合为空时选择排序结束。

常见选择排序算法:
直接选择排序堆排序


直接选择排序

基本思想:从待排序的数据元素集合中选取关键字最小的数据元素并将它与原始数据元素集合中的第一个数据元素交换位置;然后从不包括第一个位置上数据元素的集合中选取关键字最小的数据元素并将它与原始数据元素集合中的第二个数据元素交换位置;如此重复,直到数据元素集合中只剩一个数据元素为止。
优点:实现简单
缺点:每次只能确定一个元素,表长为n时需要n-1次。

算法如下:
void SelectSort(DataType a[], int n)
{
	int i, j, small;
	DataType temp;

	for(i = 0; i < n-1; i++)				
	{
		small = i;	 		//设第i个数据元素关键字最小
		for(j = i+1; j < n; j++)		//寻找关键字最小的数据元素
		if(a[j].key < a[small].key) small=j;//记住最小元素的下标

		if(small != i)		//当最小元素的下标不为i时交换位置
		{
			temp = a[i];
			a[i] = a[small];
			a[small] = temp;
		}
	}
}

直接选择排序.png

时间效率: O(n的2次方)——虽移动次数较少,但比较次数多。
空间效率:O(1)——没有附加单元(仅用到1个temp)
算法的稳定性:不稳定


堆排序

基本思想:把待排序的数据元素集合构成一个完全二叉树结构,则每次选择出一个最大(或最小)的数据元素只需比较完全二叉树的高度次,即log2n次,则排序算法的时间复杂度就是O(nlog2n)。

堆的定义
一、堆的定义
堆分为最大堆和最小堆两种。定义如下:
设数组a中存放了n个数据元素,数组下标从0开始,如果当数组下标2i+1<n时有:a[i].key≥a[2i+1].key(a[i].key≤a[2i+1].key);如果当数组下标2i+2<n时有:a[i].key≥a[2i+2].key (a[i].key≤a[2i+2].key),则这样的数据结构称为最大堆(最小堆)。
堆1.png

性质
(1)最大堆的根结点是堆中值最大的数据元素,最小堆的根结点是堆中值最小的数据元素,我们称堆的根结点元素为堆顶元素。
(2)对于最大堆,从根结点到每个叶结点的路径上,数据元素组成的序列都是递减有序的;对于最小堆,从根结点到每个叶结点的路径上,数据元素组成的序列都是递增有序的。

创建堆

从最后一个叶子结点开始往前逐步调整,让每个双亲大于(或小于)子女,直到根结点为止。

初始化创建堆排序

void InitCreatHeap(DataType a[], int n)	
{
	int i;

	for(i = (n-1)/2; i >= 0; i--)	
		CreatHeap(a, n, i);
}

创建堆

void CreatHeap (DataType a[], int n, int h)
{
	int i, j, flag;
	DataType temp;
	i = h;				// i为要建堆的二叉树根结点下标
	j = 2*i+1;			// j为i的左孩子结点的下标
	temp = a[i];
	flag = 0;
	//沿左右孩子中值较大者重复向下筛选
	while(j < n && flag != 1)
	{	//寻找左右孩子结点中的较大者,j为其下标
		if(j < n-1 && a[j].key < a[j+1].key) j++;
		if(temp.key > a[j].key)			//a[i].key>a[j].key
			flag=1;				//标记结束筛选条件
		else					//否则把a[j]上移
		{
			a[i] = a[j];
			i = j;
			j = 2*i+1;
		}
	}

	a[i] = temp;				//把最初的a[i]赋予最后的a[j]
}
堆排序

堆排序的基本思想:循环执行如下过程直到数组为空:
(1)把堆顶a[0]元素(为最大元素)和当前最大堆的最后一个元素交换;
(2)最大堆元素个数减1;
(3)由于第(1)步后根结点不再满足最大堆的定义,所以调整根结点使之满足最大堆的定义。

void HeapSort(DataType a[], int n)
{
	int i;
	DataType temp;   
	InitCreatHeap(a, n);		//初始化创建最大堆
	for(i = n-1; i > 0; i--)		//当前最大堆个数每次递减1
	{
	//把堆顶a[0]元素和当前最大堆的最后一个元素交换
                             temp = a[0];
		a[0] = a[i];
		a[i] = temp;
		CreatHeap(a, i, 0);	//调整根结点满足最大堆
	}
}

堆排序.png

算法分析:
时间效率:O(nlog2n)。因为整个排序过程中需要调用n-1次堆顶点的调整,而每次堆排序算法本身耗时为log2n;
空间效率:O(1)。仅在第二个for循环中交换记录时用到一个临时变量temp。
稳定性: 不稳定。
优点:对小文件效果不明显,但对大文件有效。

总结:创建堆时,其实就是比较父节点与左右孩子的大小,如果父节点较小,则与孩子结点中较大的那个互换,然后再重复此过程向下。对每个父节点都进行相同的操作。 堆排序则是创建了n-1次最大堆或最小堆,但是每次堆中元素都减少一个。

如果不太理解,下列网址中的例子可能较为简单:https://www.cnblogs.com/payapa/p/11192303.html


0x04 交换排序

基本思想:交换数据元素的位置,来实现排序。
常见有:冒泡排序快速排序


冒泡排序

基本思想:每趟不断将记录两两比较,并按“前小后大”(或“前大后小”)规则交换。

优点:每趟结束时,不仅能挤出一个最大值到最后面位置,还能同时部分理顺其他元素;一旦下趟没有交换发生,还可以提前结束排序。

冒泡排序.png

算法分析:
最好情况:初始排列已经有序,只执行一趟起泡,做 n-1 次关键码比较,不移动对象。
最坏情形:初始排列逆序,算法要执行n-1趟起泡,第i趟(1≤ i ≤ n) 做了n- i 次关键码比较,执行了n-i 次对象交换。此时的比较总次数和记录移动次数为:
冒泡排序2.png

时间效率:O(n的2次方) —考虑最坏情况
空间效率:O(1) —只在交换时用到一个缓冲单元
稳定性: 稳定

核心代码如下:

flag = 1;
temp = a[j];
a[j] = a[j+1];
a[j+1] = temp;

完整代码较为简单,故省略

快速排序

基本思想:从待排序列中任取一个元素 (例如取第一个) 作为中心,所有比它小的元素一律前放,所有比它大的元素一律后放,形成左右两个子表;然后再对各子表重新选择中心元素并依此规则调整,直到每个子表的元素只剩一个。此时便为有序序列了。
优点:每趟可以确定不止一个元素的位置,而且呈指数增加,所以特别快。

详见下列网址:
https://www.runoob.com/w3cnote/quick-sort.html


0x05 归并排序


归并排序主要是二路归并排序。
基本思想:可以把一个长度为n 的无序序列看成是 n 个长度为 1 的有序子序列 ,首先做两两归并,得到 n / 2 个长度为 2 的有序子序列 ;再做两两归并,…,如此重复,直到最后得到一个长度为 n 的有序序列。

下面是一个例子,具体算法略(看参考https://www.runoob.com/w3cnote/implementation-of-merge-sort.html)。

归并排序.png

时间效率:O(nlogn)
空间效率:O(n)
稳定性:稳定


0x06 基数排序


基数排序也称作桶排序,是一种当关键字为整数类型时非常高效的排序方法。

其实是分别对个位数、十位数等按照从大到小(从小到大)多次排序,直到最高位,此时按照队列取出来就是有序的。

设有一个初始序列为: R {50, 123, 543, 187, 49, 30, 0, 2, 11, 100}。

对于任何一个十进制数,它的各个位数上的基数都是以09来表示的。所以我们不妨把09视为10个桶。

我们先根据序列的个位数的数字来进行分类,将其分到指定的桶中。例如:R[0] = 50,个位数上是0,将这个数存入编号为0的桶中。分类后,我们在从各个桶中,将这些数按照从编号0到编号9的顺序依次将所有数取出来。这时,得到的序列就是个位数上呈递增趋势的序列。 按照个位数排序: {50, 30, 0, 100, 11, 2, 123, 543, 187, 49}。

接下来,可以对十位数、百位数也按照这种方法进行排序,最后就能得到排序完成的序列。

代码略。

时间复杂度:O(mn)
空间复杂度:O(n)
稳定性:稳定(一直前后有序)


0x07 各类排序方法比较


排序比较.png

Ch10 查找

0x01 基本概念

查找:查询关键字是否在(数据元素集合)表中的过程。也称作检索。
主关键字:能够惟一区分各个不同数据元素的关键字。
次关键字:通常不能惟一区分各个不同数据元素的关键字。
查找成功:在数据元素集合中找到了要查找的数据元素。
查找不成功:在数据元素集合中没有找到要查找的数据元素。
静态查找:只查找,不改变数据元素集合内的数据元素。
动态查找:既查找,又改变(增减)集合内的数据元素。
静态查找表:静态查找时构造的存储结构。
动态查找表:动态查找时构造的存储结构。
平均查找长度:查找过程所需进行的关键字比较次数的平均值,是衡量查找算法效率的最主要标准。

0x02 静态查找表

静态查找表主要有三种结构。
顺序表,有序顺序表,索引顺序表。

顺序表

在顺序表上查找的基本思想是:从顺序表的一端开始,用给定数据元素的关键字逐个和顺序表中各数据元素的关键字比较,若在顺序表中查找到要查找的数据元素,则查找成功,函数返回该数据元素在顺序表中的位置;否则查找失败,函数返回-1。

int SeqSearch(DataType a[], int n, KeyType key)
//在a[0]--a[n-1]中顺序查找关键码为key的数据元素
//查找成功时返回该元素的下标序号;失败时返回-1
{
	int i = 0;
	while(i < n && a[i].key != key) i++;

	if(a[i].key == key) return i;
	else return -1;
}

算法效率分析:
顺序查找.png

有序顺序表

一种方法是和顺序表查找方法类似。
另一种方法是二分查找
算法的基本思想:先给数据排序(例如按升序排好),形成有序表,然后再将key与正中元素相比,若key小,则缩小至前半部内查找;再取其中值比较,每次缩小1/2的范围,直到查找成功或失败为止。反之,如果key大,则缩小至后半部内查找。

int BiSearch(DataType a[], int n, KeyType key)
//在有序表a[0]--a[n-1]中二分查找关键码为key的数据元素
//查找成功时返回该元素的下标序号;失败时返回-1
{
	int low = 0, high = n - 1;		//确定初始查找区间上下界
	int mid;

	while(low <= high)
	{
		mid = (low + high)/2;		//确定查找区间中心下标

		if(a[mid].key == key) return mid;		//查找成功
		else if(a[mid].key < key) low = mid + 1;
		else high = mid - 1;
	}

	return -1;					//查找失败
}

算法效率分析:
有序顺序表.png

索引顺序表

当顺序表中的数据元素个数非常大时,采用在顺序表上建立索引表的办法提高查找速度。把要在其上建立索引表的顺序表称作主表。主表中存放着数据元素的全部信息,索引表中只存放主表中要查找数据元素的主关键字和索引信息。

完全索引表:和主表项完全相同,但只包含索引关键字和该数据元素在主表中位置信息的索引表
二级索引表:当主表中的数据元素个数非常庞大时,按照建立索引表的同样方法对索引表再建立的索引表。二级以上的索引结构称作多级索引结构
等长索引表:索引表中的每个索引项对应主表中的数据元素个数相等;反之称为不等长索引表。不等长索引表中的索引长度可随着动态插入和动态删除过程改变,因此不仅适用于静态查找问题,而且也适用于动态查找问题。

索引表结构图.png

假设索引表的长度为m,主表中每个子表的长度为s,并假设在索引表上和在主表上均采用顺序查找算法,则索引顺序表上查找算法的平均查找长度为:

0x03 动态查找表

主要有二叉树结构树结构两种类型。
二叉树结构:二叉排序树、平衡二叉树等
树结构:B-树,B+树等

二叉排序树

或是一棵空树;或者是具有如下性质的非空二叉树:
(1)左子树的所有结点均小于根的值;
(2)右子树的所有结点均大于根的值;
(3)它的左右子树也分别为二叉排序树。

构造过程略。

查找过程,遍历二叉排序树,并在遍历过程中寻找要查找的数据元素是否存在。

template <class T>
BiTreeNode<T>* BiSearchTree<T>::Find(const T &item)
{
	if(root != NULL)
	{
		BiTreeNode<T> *temp = root;
		while(temp != NULL)
		{
			if(temp->data == item) return temp;	//查找成功

			if(temp->data < item) 
				temp = temp->Right();    	//在右子树继续
			else 
				temp = temp->Left();	//在左子树继续
		}
	}
	return NULL;					//查找失败
}

插入过程
插入操作中首先查找数据元素是否在二叉排序树中存在,若存在则返回;若不存在,插入到查找失败时结点的左指针或右指针上。

template <class T>
void BiSearchTree<T>::Insert(BiTreeNode<T>* &ptr, const T &item)
{
	if(ptr == NULL)
	ptr = new BiTreeNode<T>(item);		//生成并插入结点
	else if(item < ptr->data) 
	Insert(ptr->Left(), item);			//在左子树递归
	else if(item > ptr->data) 
	Insert(ptr->Right(), item);		//在右子树递归
}

删除算法
删除操作要求首先查找数据元素是否在二叉排序树中存在,若不存在则结束;存在的情况及相应的删除方法有如下四种:
(1)要删除结点无孩子结点,直接删除该结点。
(2)要删除结点只有左孩子结点,删除该结点且使被删除结点的双亲结点指向被删除结点的左孩子结点。
(3)要删除结点只有右孩子结点,删除该结点且使被删除结点的双亲结点指向被删除结点的右孩子结点。
(4)要删除结点有左右孩子结点,分如下三步完成:首先寻找数据元素的关键字值大于要删除结点数据元素关键字的最小值,即寻找要删除结点右子树的最左结点;然后把右子树的最左结点的数据元素值拷贝到要删除的结点上;最后删除右子树的最左结点。

二叉树删除.png

二叉树删除2.png

性能分析:
若每个数据元素的查找概率相等,则二叉排序树查找成功的平均查找长度为ASL成功=log(n+1)

当二叉排序树是一棵单分支退化树时,查找成功的平均查找长度和有序顺序表的平均查找长度相同。即ASL成功=(n+1)/2

最坏情况下,平均查找长度O(n),一般情况下平均查找长度为O(logn)

B-树

B_树是一种平衡多叉排序树。平衡是指所有叶结点都在同一层上,从而可避免出现像二叉排序树那样的分支退化现象。因此B_树的动态查找效率更高。

B_树中所有结点的孩子结点的最大值称为B_树的阶,一棵m阶的B_树或者是一棵空树,或者是满足下列要求的m叉树:
(1)树中每个结点至多有m个孩子结点。
(2)除根结点外,其他结点至少有m/2(向上取整)个孩子结点。
(3)若根结点不是叶结点,则根结点至少有两个孩子结点;
(4)所有叶结点都在同一层上。
(5)每个结点的结构为:
b-树结点结构.png

b-树.png

查找算法

在B_树上查找数据元素x的方法为:将 x.key与根结点的Ki逐个进行比较:

(1)若x.key=Ki则查找成功。
(2)若key<K1则沿着指针P0所指的子树继续查找。
(3)若Ki<key<Ki+1则沿着指针Pi所指的子树继续查找。
(4)若key>Kn则沿着指针Pn所指的子树继续查找。
插入算法

分两步:
(1)利用查找算法找出该关键字的插入结点。
(2)判断该结点是否还有空位置,即判断该结点是否满足n<m-1,若该结点满足n<m-1,说明该结点还有空位置,直接把关键字x.key插入到该结点的合适位置上;若该结点有n=m-1,说明该结点已没有空位置,要插入就要分裂该结点。(结点分裂具体方法略)

在3阶B_树上进行插入操作如下图示:
b-树插入1.png

b-树插入2.png

b-树插入3.png

删除算法

分两步
(1)利用查找算法找出该关键字的插入结点。
(2)在结点上删除关键字x.key分两种情况

  • 一种是在叶结点上删除(又分为三种情况):

(a)假如要删除关键字结点的关键字个数n大于m/2-1,说明删去该关键字后该结点仍满足B_树的定义,则可直接删去该关键字。
(b)假如要删除关键字结点的关键字个数n等于m/2-1,说明删去该关键字后该结点将不满足B_树的定义,此时若该结点的左(或右)兄弟结点中关键字个数n大于m/2-1,则把该结点的左(或右)兄弟结点中最大(或最小)的关键字上移到双亲结点中,同时把双亲结点中大于(或小于)上移关键字的关键字下移到要删除关键字的结点中,这样删去关键字后该结点以及它的左(或右)兄弟结点都仍旧满足B_树的定义。
(c)假如要删除关键字结点的关键字个数n等于[m/2]-1并且该结点的左和右兄弟结点(如果存在的话)中关键字个数n均等于[m/2]-1,这时需把要删除关键字的结点与其左(或右)兄弟结点以及双亲结点中分割二者的关键字合并成一个结点。

  • 另一种是在非叶结点上删除关键字。

在非叶结点上删除关键字时,假设要删除关键字Ki(1≤i≤n),在删去该关键字后,以该结点Pi所指子树中的最小关键字Kmin来代替被删关键字Ki所在的位置(Pi所指子树中的最小关键字Kmin一定是在叶结点上),然后再以指针Pi所指结点为根结点查找并删除Kmin(在非叶结点上删除问题就转化成了叶结点上的删除问题)。

b树删除1.png

b树删除2.png

b树删除3.png

0x04 哈希表

基本概念

哈希函数:数据元素的关键字和该数据元素的存放位置之间的映射函数
哈希表:通过哈希函数来确定数据元素存放位置的一种特殊表结构。

设要存储的数据元素个数为n,设置一个长度为m(m≥n)的连续内存单元,分别以每个数据元素的关键字Ki为(0≤i≤n-1)为自变量,通过哈希函数h(Ki),把Ki映射为内存单元的某个地址h(Ki),并把该数据元素存储在这个内存单元中。哈希函数h(Ki)实际上是关键字Ki到内存单元的映射,因此,h(Ki)也称为哈希地址,哈希表也称作散列表

哈希冲突:Ki≠Kj(i≠j),但h(Ki)=h(Kj)的现象称作哈希冲突。这种具有不同关键字而具有相同哈希地址的数据元素称作“同义词”,由同义词引起的冲突称作同义词冲突

构造哈希表时,冲突是难以避免的,有关因素有如下三个:
(1)装填因子。装填因子是指哈希表中已存入的数据元素个数n与哈希地址空间大小m的比值,即α=n/m,α越小,冲突的可能性就越小,但哈希表中空闲单元的比例就越大; α越大(最大可取1)时,冲突的可能性就越大,但哈希表中空闲单元的比例就越小,存储空间的利用率就越高。
(2)与所采用的哈希函数有关。
(3)与解决哈希冲突的哈希冲突函数有关。

解决哈希冲突的基本思想是通过哈希冲突函数(设为hl(K)(l=1,2,…,m-1))产生一个新的哈希地址使hl(Ki)≠hl(Kj)

哈希函数的构造方法

常用方法:
除留余数法、直接定址法、数字分析法。

哈希函数构造.png
三、数字分析法
特点:取数据元素关键字中某些取值较均匀的数字位作为哈希地址,只适合于所有关键字值已知的情况

哈希冲突解决方法

开放定址法、链表法

开放定址法

思路:以发生哈希冲突的哈希地址为自变量、通过某种哈希冲突函数得到一个新的空闲的内存单元地址。

  • 线性探查法
    哈希冲突线性探查法.png
  • 平方探查法
    哈希冲突平方探查法.png
  • 伪随机数法
    哈希冲突伪随机数法.png
链表法

思路:如果没有发生哈希冲突,则直接存放该数据元素;如果发生了哈希冲突,则把发生哈希冲突的数据元素另外存放在单链表中(方法有两种:第一种方法是为发生哈希冲突的不同的同义词建立不同的单链表;第二种方法是为发生哈希冲突的所有同义词建立一个单链表。)。

例题
建立数据元素集合a的哈希表。a = {16, 74, 60, 43, 54, 90, 46, 31, 29, 88, 77, 66, 55}。要求哈希函数采用除留余数法,解决冲突方法采用链表法。
分析:
数据元素集合a中共有13个数据元素,取哈希表的内存单元个数m=13。除留余数法的哈希函数为:h(K) = K mod m
则有:

		h(16) = 3 	h(74) = 9	h(60) = 8
		h(43) = 4 	h(54) = 2	h(90) = 12
		h(46) = 7 	h(31) = 5	h(29) = 3
		h(88) = 10 	h(77) = 12	h(66) = 1
                    h(55) = 3

采用链表法的第一种方法建立的哈希表存储结构如下图所示。
哈希表链表法.png

posted @ 2020-07-07 08:02  LieDra  阅读(701)  评论(0编辑  收藏  举报