第2章 算法
参考文章
2.9 算法时间复杂度
T
(
n
)
=
O
(
f
(
n
)
)
T(n) = O( f(n) )
T(n)=O(f(n))
上述公式表示随时间规模 n 的增大,算法执行时间的增长率和
f
(
n
)
f(n)
f(n) 的增长率相同,称作算法的渐近时间复杂度,简称为时间复
杂度,该表示方法为大
O
O
O 记法。。其中
T
(
n
)
T(n)
T(n)表示语句总的执行次数,
f
(
n
)
f(n)
f(n) 是问题规模n的某个函数。一般情况下,随着n的增大,
T
(
n
)
T(n)
T(n) 增长最慢的算法为最优算法。
如何推到大O阶算法
- 用常数1 取代运行时间中的所有加法常数。
- 在修改后的运行次数函数中,只保留最高阶项 。
- 如果最高阶项存在且不是 1 ,则去除与这个项相乘的常数。
得到的结果就是大 O 阶。
2.10 常见的时间复杂度
常见排序算法的时间复杂度
2.12 空间复杂度
S
(
n
)
=
O
(
f
(
n
)
)
S(n) = O(f(n))
S(n)=O(f(n))
空间复杂度是对一个算法在运行过程中临时占用存储空间大小的一个量度,同样反映的是一个趋势,我们用
S
(
n
)
S(n)
S(n) 来定义。
第3章 线性表
3.2 线性表的定义
线性表(List):零个或者多个数据元素的有限序列。
举例: A B C…
上例中,B直接前驱元素为 A;B直接后驱元素为C。在线性表中,除首位元素外,中间数据单元的前驱和后驱元素都是唯一的。否则,就不是线性表。
3.2.1 线性表的实现
#include<iostream>
#include"stdio.h"
using namespace std;
constexpr auto MAXSIZE = 20;
constexpr auto OK = 1;
constexpr auto ERROR = 0;
constexpr auto TRUE = 1;
constexpr auto FALSE = 0;
typedef int ElemType;
typedef int Status;
typedef struct
{
ElemType data[MAXSIZE];
int length;
}SqList;
Status ListInsert(SqList *L,int i,ElemType e);
Status ListDelete(SqList *L,int i,ElemType e);
//获取线性表元素的操作
Status GetElem(SqList L,int i,ElemType *e)
{
if (L.length==0 || i<=0 || i>L.length)
{
return ERROR;
}
*e = L.data[i-1]; // 这个能拿到?
return OK;
}
//在L中第i个位置插入元素e的函数(第i个位置不是数组中的下标,是一个个数过去的i个数字)
Status ListInsert(SqList* L, int i, ElemType e)
{
if (L->length==MAXSIZE)
{
return ERROR;
}
if (i<1 || i>L->length+1)
{
return ERROR;
}
if (i<=L->length)
{
for (int k = L->length-1; k >= i-1; k--)
{
L->data[k + 1] = L->data[k];
}
}
L->data[i - 1] = e;
L->length++;
return OK;
}
//删除L中的第i个数据元素,并用e返回其值,L的长度减1
Status ListDelete(SqList* L, int i, ElemType e)
{
if (L->length==0)
{
return ERROR;
}
if (i<1 || i>L->length)
{
return ERROR;
}
e = L->data[i-1];
if (i<L->length) // 所有的元素要往前移动一位
{
for (int k = i; k < L->length; k++)
{
L->data[k - 1] = L->data[k];
}
}
L->length--;
return OK;
}
int main()
{
SqList L;
L.length = 7;
cout << "请输入" << L.length << "个数字:" << endl;
for ( int i = 0; i <= L.length -1 ; i++)
{
cin >> L.data[i];
}
ListInsert(&L,4,9);
cout << "插入数字后的结果是:" << endl;
for (int i = 0; i <= L.length -1; i++)
{
cout << L.data[i] << " ";
}
cout << endl;
ListDelete(&L, 4, 9);
cout << "删除数字后的结果是:" << endl;
for (int i = 0; i <= L.length - 1; i++)
{
cout << L.data[i] << " ";
}
cout << endl;
ElemType getTarget;
GetElem(L, 1, &getTarget);
std::cout << "getTarget: " << getTarget << std::endl;
// testm(&getTarget);
// std::cout << "getTarget: " << getTarget << std::endl;
return 0;
}
3.4 线性表的顺序存储结构
std::array和std::vector就是数据的顺序存储结构,从内存占用看起分配的都是连续的内存空间。
优点
(1) 可以通过下表进行索引,查找元素非常便捷;
(2) 在初始内存足够的前提下,增加元素无需额外申请存储空间;
缺点
(1) 对线性表中数据进行增删操作时,有可能需要移动所有的元素;
3.6线性表的链式存储结构
3.6.2线性表链式存储结构定义
- 结点(node)
- 数据域:存储数据元素信息的域
- 指针域:存储直接后继位置域
- 开始结点: 是指链表中的第一个结点,它没有直接前驱
- 头指针:链表中第一个结点的存储位置叫做头指针一个单链表可以由其头指针唯一确定,一般用其头指针来命名单链表
- 头结点:是在链表的开始结点之前附加的一个结点
3.8 单链表的插入与删除
#include <iostream>
#include <memory>
using namespace std;
struct ListNode
{
int val;
ListNode *next;
std::unique_ptr<ListNode> nextNode; // 智能指针构建结点
ListNode(int x):val(x), next(NULL), nextNode(new ListNode(NULL)) {}
};
class MList
{
public:
MList(){}
~MList(){}
void Insert(int val, int index);
void Delete(int index);
void Create(int n);
void Show();
ListNode *getListHead()
{
if(head_) return head_;
return NULL;
}
private:
ListNode *head_;
unsigned int length_;
};
void MList::Create(int n)
{
ListNode* head = new ListNode(0);
ListNode* pre = nullptr ;
for(int i = 0; i < n; i++)
{
ListNode* temp = new ListNode(i);
pre->next = temp;
pre = temp;
}
head_ = head;
}
void MList::Insert(int val, int index)
{
if(index > length_ || index < 0)
{
std::cout << "Invalid index" << std::endl;
}
ListNode* pHead = head_;
ListNode* pNode = NULL;
ListNode* newNode = new ListNode(val); // 申请内存需要手动释放
if(index == 0)
{
pNode = head_;
head_ = newNode;
head_->next = pNode;
}
}
void MList::Delete(int index)
{
if(index == 0)
{
head_ = head_->next;
}
ListNode* pre = nullptr;
ListNode* node = head_;
while(index > 0)
{
pre = node;
node = node->next;
index--;
}
pre->next = node->next;
delete node; // 释放创建时占用的内存
}
void MList::Show()
{
ListNode *pNode = nullptr;
if(head_)
{
pNode = head_;
}
while(pNode->next)
{
cout << pNode->val << " ";
pNode = pNode->next;
}
cout << endl;
}
void testMList()
{
MList mList;
}
3.11 单链表和顺序存储的优缺点
(1) 存储分配方式
- 顺序存储是分配一块连续的内存空间
- 单链表的内存并不必须是连续的
(2) 时间性能
- 顺序存储的查找性能是O(1), 但是删除和插入是O(n)
- 单链表存储的查找性能是O(n), 但是删除和插入是O(1)
(3) 空间性能
- 顺序存储需要事先分配好一块连续内存,容易造成内存浪费
- 单链表的内存是动态分配,无需事先分配
3.12 静态链表
静态链表主要用于不支持指针的程序设计语言中。(对其在C或者Java程序设计中的实用性表示怀疑)
3.13 循环链表
将单链表中终端结点的指针端由空指针改为指向头指针,就使整个单链表形成一个环,这种头尾相接的单链表称为单循环链表,简称循环链表。
3.14双向链表
双向链表是在单链表的每个结点中,再设置一个指向其前驱结点的指针域
双向链表的插入和删除:
// 插入:
s -> prior = p;
s -> next = p -> next;
p -> next -> prior = s;
p -> next = s;
// 删除:
p -> prior -> next = p -> next;
p -> next -> prior = p ->prior;
free(p);
3.15 总结回顾
线性表
- 顺序存储结构
- 链式存储结构
- 单链表
- 静态链表
- 循环链表
- 双向链表
第4章 栈和队列
- 栈是限定仅在表尾进行插入和删除操作的线性表
- 队列是只允许在一端进行插入操作,而在另一段进行删除操作的线性表
4.2 栈的定义
栈是一种仅能在表尾部进行数据插入和删除操作的线性表,其是一种后进先出的结构。
4.3 栈的抽象数据类型
栈的一些基本操作:
ADT 栈(stack)
Data
同线性表.元素具有相同的类型,相邻元素具有前驱和后继关系
Operation
InitStack(*S)
DestroyStack(*S)
ClearStack(*S)
StackEmpty(*S)判断堆栈是否为空,返回值是ture和false
GetTop(*S)
Push(*S, e)插入e值
POp(*S, *e)删除栈顶元素并用e返回其值
StackLength(S)
4.4 栈的顺序存储结构及实现(P93)
利用下标为0的数组一端作为栈的栈底
判断空栈的条件是top=-1
**销毁一个栈: **释放内存
**清空一个栈: **只改变地址
4.5两栈共享空间
两栈共享空间结构:使用数组同时实现两个栈,即栈1和栈2;栈1为空时,栈1的栈顶指针指向-1;栈2为空时,栈2 的栈顶指针指向MAXSIZE;栈1和栈2添加元素时,都会向数据中间靠拢,当栈1的指针+1等于栈2的指针的时候,栈满。
4.6 栈的链式存储结构及实现
栈顶指针和单链表的头指针合二为一.
#ifndef _MY_STACK_H
#define _MY_STACK_H
template<typename T>
class LinkStack;
//定义一个栈元素节点类
template<typename T>
class StackNode
{
friend class LinkStack<T>;//声明栈类为节点类的友元,以便访问其私有数据
public:
StackNode(){};
StackNode(T _data) :data(_data), link(nullptr){};
~StackNode(){};
private:
T data;
StackNode<T>* link;
};
//栈类
template<typename T>
class LinkStack
{
public:
LinkStack();
~LinkStack();
void Push(const T& _data);//入栈
void Pop();//删除
T& Top();//返回栈顶数据
bool Empty();//判断栈是否为空
int Size();//返回栈的大小
private:
StackNode<T> *top;//栈顶指针
int size;//栈当前的大小
};
//构造函数和析构函数
template <typename T>
LinkStack<T>::LinkStack()
:top(nullptr), size(0)
{
}
template <typename T>
LinkStack<T>::~LinkStack()
{
while (!Empty()){
Pop();
}
}
//push函数
template <typename T>
void LinkStack<T>::Push(const T& _data)
{
StackNode<T> *tmp = new StackNode<T>(_data);//生成一个新的栈元素节点,并赋初值_data
tmp->link = top;//push的元素节点指向原来的栈顶
top = tmp;//push的元素节点做为栈顶
size++;//栈的大小+1
}
//pop函数
template <typename T>
void LinkStack<T>::Pop()
{
if (!Empty()){
StackNode<T> *tmp = top->link;//保存栈顶的下一个元素,因为它将成为栈顶
delete top;
top = tmp;
size--;//栈大小-1
}
else exit(1);
}
//返回栈顶数据
template <typename T>
T& LinkStack<T>::Top()
{
if (!Empty())
return top->data;//栈不空返回栈顶数据
else
exit(1);
}
//检查栈是否为空
template <typename T>
bool LinkStack<T>::Empty()
{
if (size > 0)
return false;
else
return true;
}
//返回栈的大小
template <typename T>
int LinkStack<T>::Size()
{
return size;
}
#endif
4.9 栈的应用–四则运算表达式求值
9+(3-1)*3+10/2 这是中缀表达式
9 3 1 - 3 * + 10 2 / + 这是后缀表达式
栈的四则运算最主要的就是让中缀表达式转换为后缀表达式,然后再进行一些其他的操作.
4.10 队列的定义
队列是只允许在一端进行插入操作,在另一端进行删除操作的线性表
- 先进先出
- 队尾: 允许插入的一端
- 队头: 允许删除的一端
4.12 循环队列
如何判断队列是否已满?利用下面共指来判断
(
r
e
a
r
−
f
r
o
n
t
+
Q
u
e
n
e
S
i
z
e
)
%
Q
u
e
n
e
S
i
z
e
=
=
f
r
o
n
t
(rear-front+QueneSize) \% QueneSize == front
(rear−front+QueneSize)%QueneSize==front
循环队列的实现
#ifndef QUEUE_XQQUEUE_H
#define QUEUE_XQQUEUE_H
#include <iostream>
using namespace std;
namespace xq
{
const int MAX_QUEUE_SIZE = 50;
template<typename T>
class LoopQueue {
private:
T *base; // 存储数据的连续数组
int front; // 头指针
int rear; // 尾指针
int size;
public:
LoopQueue();
LoopQueue(int);
bool insert(T);
bool delete_(T *);
bool isEmpty();
void print();
};
template<typename T>
LoopQueue<T>::LoopQueue() {
base = new T[MAX_QUEUE_SIZE]; // 存储空间初始化
front = rear = 0;
size = MAX_QUEUE_SIZE;
}
template<typename T>
LoopQueue<T>::LoopQueue(int n) {
base = new T[n]; // 该队存储空间中只能存储n-1个有效数据,其余一个是被rear指针占用
front = rear = 0;
size = n;
}
template<typename T>
bool LoopQueue<T>::isEmpty() {
if (rear == front)
return true;
return false;
}
template<typename T>
bool LoopQueue<T>::insert(T num) {
if ((rear + 1) % size == front) {
cout << "queue is full!" << endl;
return false;
}
base[rear % size] = num;
rear = (rear + 1) % size;
}
template<typename T>
void LoopQueue<T>::print() {
if (isEmpty()) {
cout << "queue is empty!" << endl;
return;
}
int p = front;
int q = rear;
while (p % size != q) {
cout << base[p] << ' ';
p = (p+1)%size;
}
cout << endl;
}
template<typename T>
bool LoopQueue<T>::delete_(T *num) { // 数据出队
if (isEmpty()) {
cout << "queue is empty!" << endl;
return false;
}
*num = base[front];
front = (front + 1) % size;
}
} // xq
#endif //QUEUE_XQQUEUE_H
第5章 串
串是由零个或者多个字符组成的有限序列,又名叫字符串.
5.2 串的定义
串是由零个或者多个字符组成的有限序列,又名叫字符串
一般记作:
s
=
"
a
1
a
2
a
3......
a
n
"
s = "a1a2a3......an"
s="a1a2a3......an"
所谓序列,是指相邻的字符串之间具有前驱和后继的关系
5.4 串的抽象数据类型
ADT 串 (String)
Data
串中的元素仅由一个字符组成,相邻元素具有前驱和后继关系.
Operation
StrAssign (&T, chars)
初始条件:chars是字符串常量。
操作结果:生成一个其值等于chars的串T。
StrCopy (&T, S)
初始条件:串S存在。
操作结果:由串S复制得串T。
StrEmpty(S)
初始条件:串S存在。
操作结果:若S为空串,则返回TRUE,否则返回FALSE。
StrCompare(S, T)
初始条件:串S和T存在。
操作结果:若S>T,则返回值>0;若S=T,则返回值=0;若S < T,则返回值 < 0。
StrLength(S)
初始条件:串S存在。
操作结果:返回S的元素个数,称为串的长度。
ClearString (&S)
初始条件:串S存在。
操作结果:将S清为空串。
Concat (&T, S1, S2)
初始条件:串S1和S2存在。
操作结果:用T返回由S1和S2联接而成的新串。
SubString(&Sub, S, pos, len)
初始条件:串S存在,1≤pos≤StrLength(S)且0≤len≤StrLength(S)-pos+1
操作结果:用Sub返回串S的第pos个字符长度为len的子串。
Index(S, T, pos)
初始条件:串S和T存在,T是非空串,1≤pos≤StrLength(S)。
操作结果:若主串S中存在和串T值相同的子串,则返回它在主串S中第pos个字符之后第一次出现的位置;否则函数值为0。
Replace (&S, T, V)
初始条件:串S, T和V存在,T是非空串。
操作结果:用V替换主串S中出现的所有与T相等的不重叠的子串。
StrInsert (&S, pos, T)
初始条件:串S和T存在, 1≤pos≤StrLength(S)+1。
操作结果:在串S的第pos个字符之前插入串T。
StrDelete (&S, pos, len)
初始条件:串S存在, 1≤pos≤StrLength(S)-len+1。
操作结果:从串S中删除第pos个字符起长度为len的子串。
DestroyString (&S)
初始条件:串S存在。
操作结果:串S被销毁。
endADT
5.5 串的存储结构
- 顺序存储
- 堆上存储
- 链式存储
/**
* @file 01_store_string.cpp
* @author your name (you@domain.com)
* @brief 串的存储主要有三种形式
* @version 0.1
* @date 2023-09-13
*
* @copyright Copyright (c) 2023
*
*/
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
/* 串的顺序存储,底层就是在栈上申请得一块连续的内存 */
int storeStringInArray()
{
char str[19]="data.biancheng.net";
printf("%s\n",str);
return 0;
}
/* 串在堆上的存储,底层就是在堆上申请得一块连续的内存 */
int storeStringInHeap()
{
char * a1 = NULL;
char * a2 = NULL;
a1 = (char*)malloc(10 * sizeof(char));
strcpy(a1, "data.bian");//将字符串"data.bian"复制给a1
a2 = (char*)malloc(10 * sizeof(char));
strcpy(a2, "cheng.net");
int lengthA1 = strlen(a1);//a1串的长度
int lengthA2 = strlen(a2);//a2串的长度
//尝试将合并的串存储在 a1 中,如果 a1 空间不够,则用realloc动态申请
if (lengthA1 < lengthA1 + lengthA2) {
a1 = (char*)realloc(a1, (lengthA1 + lengthA2+1) * sizeof(char));
}
//合并两个串到 a1 中
for (int i = lengthA1; i < lengthA1 + lengthA2; i++) {
a1[i] = a2[i - lengthA1];
}
//串的末尾要添加 \0,避免出错
a1[lengthA1 + lengthA2] = '\0';
printf("%s", a1);
//用完动态数组要立即释放
free(a1);
free(a2);
return 0;
}
/* 串的链式存储,其底层是在堆上申请的非连续的内存块 */
#define linkNum 3//全局设置链表中节点存储数据的个数
typedef struct Link {
char a[linkNum]; //数据域可存放 linkNum 个数据
struct Link * next; //代表指针域,指向直接后继元素
}link; // nk为节点名,每个节点都是一个 link 结构体
//初始化链表,其中head为头指针,str为存储的字符串
link * initLink(link * head, char * str) {
int length = strlen(str);
//根据字符串的长度,计算出链表中使用节点的个数
int num = length/linkNum;
if (length%linkNum) {
num++;
}
//创建并初始化首元节点
head = (link*)malloc(sizeof(link));
head->next = NULL;
link *temp = head;
//初始化链表
for (int i = 0; i<num; i++)
{
int j = 0;
for (; j<linkNum; j++)
{
if (i*linkNum + j < length) {
temp->a[j] = str[i*linkNum + j];
}
else
temp->a[j] = '#';
}
if (i*linkNum + j < length)
{
link * newlink = (link*)malloc(sizeof(link));
newlink->next = NULL;
temp->next = newlink;
temp = newlink;
}
}
return head;
}
//输出链表
void displayLink(link * head) {
link * temp = head;
while (temp) {
for (int i = 0; i < linkNum; i++) {
printf("%c", temp->a[i]);
}
temp = temp->next;
}
}
int storeStringInLinkTest()
{
link * head = NULL;
head = initLink(head, "data.biancheng.net");
displayLink(head);
return 0;
}
5.6 KMP模式匹配算法
问题描述
给定一个主串S及一个模式串P,判断模式串是否为主串的子串;若是,返回匹配的第一个元素的位置(序号从1开始),否则返回0;如S=“abcd”,P=“bcd”,则返回2;S=“abcd”,P=“acb”,返回0。
KMP算法
https://blog.youkuaiyun.com/qq_37969433/article/details/82947411
朴素算法理解简单,但两个串都有依次遍历,时间复杂度为O(n*m),效率不高。由此有了KMP算法。
一般的,在一次匹配中,我们是不知道主串的内容的,而模式串是我们自己定义的。
朴素算法中,P的第j位失配,默认的把P串后移一位。
但在前一轮的比较中,我们已经知道了P的前(j-1)位与S中间对应的某(j-1)个元素已经匹配成功了。这就意味着,在一轮的尝试匹配中,我们get到了主串的部分内容,我们能否利用这些内容,让P多移几位(我认为这就是KMP算法最根本的东西),减少遍历的趟数呢?
第6章 树
6.2 树的定义
树是n个结点的有限集合.n=0时称为空树.在任意一棵非空树中:
- 有且仅有一个特定的称为根(Root)的结点;
- 当n>1时,其余结点可以分为m个互不相交的有限集T1,T2…Tm,其中每一个集合本身又是一课树,并且称为根的子树(SubTree)
- 度: 结点拥有的子树的个数称为结点的度(Degree),度为0的结点称为叶节点或者终端结点.度不为0的结点称为非终端结点或者分支结点
- 树的度是树的各个结点度的最大值
- 深度或高度: 树中结点的最大层次称为树的深度或高度
- 有序树和无序树
- 森林是m棵互不相交的树的集合
6.4 树的存储结构(P155)
- 双亲表示法
- 孩子表示法
- 孩子兄弟表示法
http://data.biancheng.net/view/30.html
6.4.1 双亲表示法
6.4.2 孩子表示法
将树中的每个结点的孩子结点排列成一个线性表,用链表存储起来。对于含有 n 个结点的树来说,就会有 n 个单链表,将 n 个单链表的头指针存储在一个线性表中,这样的表示方法就是孩子表示法。
使用孩子表示法存储的树结构,正好和双亲表示法相反,适用于查找某结点的孩子结点,不适用于查找其父结点。可以将两种表示方法合二为一,存储效果如图 :
#define TElemType int
#define Tree_Size 100
//孩子表示法
typedef struct CTNode{
int child;//链表中每个结点存储的不是数据本身,而是数据在数组中存储的位置下标
struct CTNode * next;
}*ChildPtr;
typedef struct {
TElemType data;//结点的数据类型
ChildPtr firstchild;//孩子链表的头指针
}CTBox;
typedef struct{
CTBox nodes[Tree_Size];//存储结点的数组
int n,r;//结点数量和树根的位置
}CTree;
6.4.3 孩子兄弟表示法
任意一棵树, 他的结点的第一个孩子如果存在就是唯一的, 他的右兄弟如果存在也是唯一的.因此我们设置两个指针,分别指向该结点的第一个孩子和此节点的右兄弟
data | firstchild | rightsib |
---|---|---|
数据域 | 孩子域 | 兄弟域 |
上下两个表格一个意思
这个表示方法就是把一个复杂的树变成了二叉树
6.5 二叉树的定义
https://www.jianshu.com/p/bf73c8d50dc2
二叉树(Binary Tree)是n(n>=0)个结点的有限集合,该集合或者为空集(称为空二叉树), 或者由一个根节点和两棵互不相交的,分别称为根节点的左子树和右子树的二叉树组成
(1) 二叉树的特点:
- 每个结点最多有两颗子树,所以二叉树中不存在度大于2的结点。
- 左子树和右子树是有顺序的,次序不能任意颠倒。
- 即使树中某结点只有一棵子树,也要区分它是左子树还是右子树。
(2) 特殊二叉树
1) 斜树: 所有的结点都只有左子树的二叉树叫左斜树。所有结点都是只有右子树的二叉树叫右斜树。这两者统称为斜树。
2)满二叉树: 在一棵二叉树中。如果所有分支结点都存在左子树和右子树,并且所有叶子都在同一层上,这样的二叉树称为满二叉树
- 叶子只能出现在最下一层。出现在其它层就不可能达成平衡;
- 非叶子结点的度一定是2;
- 在同样深度的二叉树中,满二叉树的结点个数最多,叶子数最多。并且叶子都处于同一层。
3)完全二叉树: 对一颗具有n个结点的二叉树按层编号,如果编号为i(1<=i<=n)的结点与同样深度的满二叉树中编号为i的结点在二叉树中位置完全相同,则这棵二叉树称为完全二叉树。
特点:
1)叶子结点只能出现在最下层和次下层。
2)最下层的叶子结点集中在树的左部。
3)倒数第二层若存在叶子结点,一定在右部连续位置。
4)如果结点度为1,则该结点只有左孩子,即没有右子树。
5)同样结点数目的二叉树,完全二叉树深度最小。
注:满二叉树一定是完全二叉树,但反过来不一定成立。
其他对完全二叉树的解释:从根往下数,除了最下层外都是全满(都有两个子节点),而最下层所有叶结点都向左边靠拢填满。 构造一颗完全二叉树就是【从上到下,从左往右】的放置节点
6.5.3 其他重要二叉树
1)二叉排序树: 又叫二叉查找树、二叉搜索树,它或者是一棵空树;或者是具有以下性质的二叉树:
- 若它的左子树不空,则左子树上所有节点的值均小于它的根节点的值;
- 若它的右子树不空,则右子树上所有节点的值均大于它的根节点的值;
- 它的所有结点的左右子树也分别为二叉排序树。
2)平衡二叉树(AVL)是一种二叉排序树,其中每个结点的左子树和右子树的高度差至多等于1。它是一种高度平衡的二叉排序树。意思是说,要么它是一棵空树,要么它的左子树和右子树都是平衡二叉树,且左子树和右子树的深度之差的绝对值不超过1。
平衡二叉树的构造过程:https://blog.youkuaiyun.com/fu_jian_ping/article/details/89292813
6.6 二叉树的性质
二叉树的五大性质及证明: https://blog.youkuaiyun.com/why_still_confused/article/details/51532222
6.7 二叉树的存储结构
6.7.1 顺序存储结构
二叉树的顺序存储,就是用一组连续的存储单元存放二叉树中的结点。即用一维数组存储二叉树中的结点。因此,必须把二叉树的所有结点安排成一个恰当的序列,结点在这个序列中的相互位置能反映出结点之间的逻辑关系。用编号的方法从树根起,自上层至下层,每层自左至右地给所有结点编号。
依据二叉树的性质,完全二叉树和满二叉树采用顺序存储比较合适,树中结点的序号可以唯一地反映出结点之间的逻辑关系,这样既能够最大可能地节省存储空间,又可以利用数组元素的下标值确定结点在二叉树中的位置,以及结点之间的关系。
一棵完全二叉树(满二叉树)如下图所示:
将这可二叉树存入到数组中,如下图所示:
但是假如要存储的二叉树是一个右斜树或者左斜树,就会造成比较大的存储空间浪费。所以使用数组存储二叉树并不是一个很好得选择。
6.7.2 二叉链表
链表每个结点包含一个数据域和两个指针域:
其中data是数据域,lchild和rchild都是指针域,分别指向左孩子和右孩子。
/*二叉树的二叉链表结点结构定义*/
typedef struct BiNode
{
char data; /*结点数据*/
struct BiNode *lchild, *rchild; /*左右孩子指针*/
}BiNode,*BiTree;
6.8 遍历二叉树
二叉树的遍历是指从根节点出发,按照某种次序依次访问二叉树中的所有结点, 使得每个结点被访问一次且仅被访问一次。
https://blog.youkuaiyun.com/gatieme/article/details/51163010(二叉树建立和遍历的C++代码实现)
(1)前序遍历
根结点 —> 左子树 —> 右子树
(2)中序遍历
左子树 —> 根节点 —> 右子树
(3)后续遍历
左子树 —> 右子树—> 根节点
(4)层序遍历
从根节点开始访问,从上而下逐层遍历,在同一层中,按照从左到右的顺序对结点逐个访问
6.9 二叉树的建立
递归创建二叉树
/**
* 另外一种链表二叉树的实现方式
*/
#include<iostream>
using namespace std;
typedef struct Node
{
int data;
Node* lchild;
Node* rchild;
}BiNode,*BiTree;
BiTree createBiTree(BiTree &T)
{
int d;
cin >> d;
if (d == 0)
{
T = NULL;
}
else
{
T = (BiTree)malloc(sizeof(BiNode));
T->data = d;
T->lchild = createBiTree(T->lchild);
T->rchild = createBiTree(T->rchild);
}
return T;
}
void PreOrder(BiTree T)//先序
{
if (T)
{
cout << T->data<<" ";
PreOrder(T->lchild);
PreOrder(T->rchild);
}
}
void InOrder(BiTree T)//中序
{
if (T)
{
InOrder(T->lchild);
cout << T->data << " ";
InOrder(T->rchild);
}
}
void PostOrder(BiTree T)//后序
{
if (T)
{
PostOrder(T->lchild);
PostOrder(T->rchild);
cout << T->data << " ";
}
}
void countT(BiTree T, int &m, int &n)
{
if (T == NULL) return ;
if (T->data % 2 != 0) n += T->data;
m++;
countT(T->lchild, m, n);
countT(T->rchild, m, n);
}
int main()
{
int m = 0,n = 0;
BiTree T=NULL;
cout << "输入先序遍历结点,建立二叉树" << endl;
T = createBiTree(T);
cout << "先序遍历结果" << endl;
PreOrder(T);
cout << endl;
cout << "中序遍历结果" << endl;
InOrder(T);
cout << endl;
cout << "后序遍历结果" << endl;
PostOrder(T);
cout << endl;
countT(T, m, n);
cout << "结点个数为:" << m << endl;
cout << "数据为:" << n << endl;
}
6.10 线索二叉树
为什么存在线索二叉树:
二叉树是一种非线性结构,遍历二叉树几乎都是通过递归或者用栈辅助实现非递归的遍历。用二叉树作为存储结构时,取到一个节点,只能获取节点的左孩子和右孩子,不能直接得到节点的任一遍历序列的前驱或者后继。为了保存这种在遍历中需要的信息,我们利用二叉树中指向左右子树的空指针来存放节点的前驱和后继信息
6.10.1 线索二叉树原理
n个节点的二叉树中含有n+1个空指针域。利用二叉树中的空指针域来存放在某种遍历次序下的前驱和后继,这种指针叫“线索”。这种加上了线索的二叉树称为线索二叉树。
- ltag为0时指向左孩子,为1时指向该节点的前驱
- rtag为0时指向右孩子, 为1时指向该节点的后继
6.11 树,森林与二叉树的转换
https://blog.youkuaiyun.com/linraise/article/details/11745559
1. 将树转换成二叉树:
(1)加线。就是在所有兄弟结点之间加一条连线;
(2)抹线。就是对树中的每个结点,只保留他与第一个孩子结点之间的连线,删除它与其它孩子结点之间的连线;
(3)旋转。就是以树的根结点为轴心,将整棵树顺时针旋转一定角度,使之结构层次分明。
2. 森林转换为二叉树
森林是由若干棵树组成,可以将森林中的每棵树的根结点看作是兄弟,由于每棵树都可以转换为二叉树,所以森林也可以转换为二叉树。
将森林转换为二叉树的步骤是:
(1)先把每棵树转换为二叉树;
(2)第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树的根结点的右孩子结点,用线连接起来。当所有的二叉树连接起来后得到的二叉树就是由森林转换得到的二叉树。
3. 二叉树转化为森林
二叉树转换为树是树转换为二叉树的逆过程,其步骤是:
(1)若某结点的左孩子结点存在,将左孩子结点的右孩子结点、右孩子结点的右孩子结点……都作为该结点的孩子结点,将该结点与这些右孩子结点用线连接起来;
(2)删除原二叉树中所有结点与其右孩子结点的连线;
(3)整理(1)和(2)两步得到的树,使之结构层次分明。
4. 二叉树转换为森林
二叉树转换为森林比较简单,其步骤如下:
(1)先把每个结点与右孩子结点的连线删除,得到分离的二叉树;
(2)把分离后的每棵二叉树转换为树;
(3)整理第(2)步得到的树,使之规范,这样得到森林。
根据树与二叉树的转换关系以及二叉树的遍历定义可以推知,树的先序遍历与其转换的相应的二叉树的先序遍历的结果序列相同;树的后序遍历与其转换的二叉树的中序遍历的结果序列相同;树的层序遍历与其转换的二叉树的后序遍历的结果序列相同。由森林与二叉树的转换关系以及森林与二叉树的遍历定义可知,森林的先序遍历和中序遍历与所转换得到的二叉树的先序遍历和中序遍历的结果序列相同。
6.12 哈夫曼树及其应用
6.12.2 哈夫曼树定义与原理(P203)
构建哈夫曼树的步骤:
哈夫曼编码:
第7章 图
https://baozouai.com/2019/03/08/%E5%A4%A7%E8%AF%9D%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84-%E7%AC%AC%E4%B8%83%E7%AB%A0%20%20%E5%9B%BE/
图(Graph)是由顶点的有穷非空集合和顶点之间的边的集合组成,通常表示为:G(V,E),其中,G表示一个图,V是图G中顶点的集合,E是图G中边的集合。
7.2 图的定义
-
无向图:若顶点Vi到Vj之间的边没有方向,则称这条边为无向边(Edge),用序偶对(Vi,Vj)表示。如果图的任意两个顶点之间的边都是无向边,则称该图是无向图。
上述无向图G1来说,G1=(V1, {E1}),其中顶点集合V1={A,B,C,D};边集合E1={(A,B),(B,C),(C,D),(D,A),(A,C)};
-
有向图:若从顶点Vi到Vj的边是有方向的,则成这条边为有向边,也称为弧(Arc)。用有序对(Vi,Vj)标示,Vi称为弧尾,Vj称为弧头。如果任意两条边之间都是有向的,则称该图为有向图。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aEpVicYr-1573180530985)(https://baozou.gitbooks.io/-data-structure/content/assets/152import.png)]
有向图G2中,G2=(V2,{E2}),顶点集合(A,B,C,D),弧集合E2={<A,D>,<B,A>,<C,A>,<B,C>}.
-
无向完全图:在一个无向图中,任意两个顶点之间都存在边,则称该图为无向完全图。含有n个顶点的无向完全图有 n ∗ ( n + 1 ) / 2 n*(n+1)/2 n∗(n+1)/2 条边
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YzB97B8u-1573180530986)(https://baozou.gitbooks.io/-data-structure/content/assets/155import.png)]
- 有向完全图:在有向图中,如果任意两个顶点之间都存在方向互为相反的两条弧,则称该图为有向完全图。含有n个顶点的有向完全图有n×(n−1)条边
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8L4L0N2g-1573180530986)(https://baozou.gitbooks.io/-data-structure/content/assets/157import.png)]
- 有很少条边或弧的图称为稀疏图,反之称为稠密图。稀疏和稠密是模糊的概念,是相对而言的
- 有些图的边或弧具有与它相关的数字,这种与图的边或弧相关的数叫做权(Weight)。这些权可以表示从一个顶点到另一个顶点的距离或耗费。这种带权的图通常称为网(Network)
- 假设有两个图G*=(V,{E})和G*′=(V′,{E′}),如果V′⊆V且E′⊆E*,则称G′为G的子图(Sub-graph)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UOuSOE7Z-1573180530987)(https://baozou.gitbooks.io/-data-structure/content/assets/160import.png)]
7.2.2 图的顶点和边的关系
7.4 图的存储结构
7.4.1 邻接矩阵
图的邻接矩阵存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二位数组(称为邻接矩阵)存储图中的边或弧的信息。
设图G有n个顶点,则邻接矩阵是一个n*n的方阵,定义为:
- 无向图的邻接矩阵
- 有向图的邻接矩阵
-
网图的邻接表
设图G是网图,有n个顶点,则邻接矩阵是一个n*n的方阵,定义为:
7.4.2 邻接表
数组与邻接表相结合的存储方法称为邻接表
-
无相图的邻接表
-
有向图的邻接表
-
网图(带权值)的邻接表
7.4.3 十字链表
十字链表 = 邻接表 + 逆邻接表
7.4.4 邻接多重表
针对无向图(存储空间压缩时用)
7.4.5 边集数组
边集数组是由两个一维数组构成。一个是存储顶点的信息;另一个是存储边的信息,这个边数组每个数据元素由一条边的起点下标(begin)、终点下标(end)和权(weight)组成
begin是存储起点下标,end是存储终点下标,weight是存储权值
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zrNe533V-1573180530987)(https://baozou.gitbooks.io/-data-structure/content/assets/import.png23)]
边集数组关注的是边的集合,在边集数组中要查找一个顶点的度需要扫描整个边数组,效率并不高。因此它更适合对边依次进行处理的操作,而不适合对顶点相关的操作
7.5 图的遍历
定义:从图的某一顶点出发访遍图中的其余顶点,且使每一个顶点仅被访问一次,这一过程就叫做图的遍历
7.5.1 深度优先遍历(P239)
从图中某个顶点v出发,访问此顶点,然后从v的未被访问的邻接点出发深度优先遍历图,直至图中所有和v有路径相通的顶点都被访问到,以上说的只是连通图,对于非连通图,只需要对它的连通分量分别进行深度优先遍历,即在先前一个顶点进行一次深度优先遍历后,若图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点作为起始点,重复上述过程,直至图中所有顶点都被访问到位置
7.5.2 广度优先算法(P242)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fDuS4knY-1573180530987)(https://baozou.gitbooks.io/-data-structure/content/assets/import.png24)]
图的深度优先遍历和广度优先遍历算法在时间复杂度上一样,不同之处仅在于对顶点的访问次序不同深度优先算法更适合目标比较明确的。以找到目标为主要目的的情况,而广度优先更适合在不断扩大遍历范围时找到相对最优解的情况
7.6 最小生成树(B站上的王卓老师讲原理讲的比较好)
生成树的特点:
- 生成树的顶点个数和图的顶点个数相同
- 生成数是图的极小联通子图,去掉一条边则非联通
- 一个有n个顶点的联通图的生成树有n-1条边
- 在生成树中再加一条边必然形成回路
- 生成树中任意两个顶点之间的路径是唯一的
最小成本,就是n个顶点,用n-1条边把一个连通图连接起来,并且使得权值的和最小
最小生成树:把构造联通网的最小代价生成树称为最小生成树。最小生成树不唯一,但是最小生成树的权值和一定唯一。
7.6.1 普利姆(prim)算法
https://www.bilibili.com/video/av36885391/?spm_id_from=333.788.videocard.0(B站)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DQVfGhpK-1573180530987)(https://baozou.gitbooks.io/-data-structure/content/assets/import.png25)]
算法实现(C++)
#include <iostream>
#define MAXINT 6
using namespace std;
//声明一个二维数组,C[i][j]存储的是点i到点j的边的权值,如果不可达,则用1000表示
//借此二维数组来表示一个连通带权图
int c[MAXINT][MAXINT]={{1000,6,1,5,1000,1000},{6,1000,5,1000,3,1000},{1,5,1000,5,6,4},{5,1000,5,1000,1000,2},{1000,3,6,1000,1000,6},{1000,1000,4,2,6,1000}};
void Prim(int n)
{
int lowcost[MAXINT];//存储S中到达对应的其它各点的最小权值分别是多少
int closest[MAXINT];//closest[]数组保存的是未在S中的点所到达S中包含的最近的点是哪一个,如:closest[i]=1表示i最靠近的S中的点是1
bool s[MAXINT];//bool型变量的S数组表示i是否已经包括在S中
int i,k;
s[0]=true;//从第一个结点开始寻找,扩展
for(i=1;i<=n;i++)//简单初始化
{
lowcost[i]=c[0][i];
closest[i]=0;//现在所有的点对应的已经在S中的最近的点是1
s[i]=false;
}
cout<<"0->";
for(i=0;i<n;i++)
{
int min=1000;//最小值,设大一点的值,后面用来记录lowcost数组中的最小值
int j=1;
for(k=1;k<=n;k++)//寻找lowcost中的最小值
{
if((lowcost[k]<min)&&(!s[k]))
{
min=lowcost[k];j=k;
}
}
cout<<j<<" "<<"->";
s[j]=true;//添加点j到集合S中
for(k=1;k<=n;k++)//因为新加入了j点,所以要查找新加入的j点到未在S中的点K中的权值是不是可以因此更小
{
if((c[j][k]<lowcost[k])&&(!s[k])){lowcost[k]=c[j][k];closest[k]=j;}
}
}
}
int main()
{
//输入初始化数组
cout<<"请输入初始权值数组:"<<endl;
for(int i=0;i<MAXINT;i++)
{
for(int j=0;j<MAXINT;j++)
{
// cout<<"please enter c["<<i<<"]["<<j<<"]:";
// cin>>c[i][j];
cout<<c[i][j]<<"\t";
// cout<<endl;
}
cout<<endl;
}
Prim(MAXINT-1);
return 0;
}
7.6.2 克鲁斯卡尔(Kruskal)算法
https://www.bilibili.com/video/av36885495/?spm_id_from=333.788.videocard.0(B站)
本质就是一种贪心算法
算法思想:
- 设联通网N=(V,E)令最小生成树初始状态为只有n个顶点而无边的非联通图T=(V,{}),每个顶点自成一个连通分量.
- 在E中选取代价最小的边,若该边依附的顶点落在T中的不同联通分量上(即:不能形成环),则将此边加入到T中,否则,舍去此边,选取一条代价最小的边.
- 以此类推,直到T中所有顶点都在同一个联通分量上为止.
两种算法的比较
算法名 | Prime算法 | Kruskal算法 |
---|---|---|
算法思想 | 选择点 | 选择边 |
时间复杂度 | O(n^2) | O(eloge) |
适应范围 | 稠密图 | 稀疏图 |
7.7 最短路径
最短路径:指两个顶点之间经过边上的权值之和最少的路径,并称第一个顶点是源点, 最后一个顶点是终点.
要解决的两类问题:
- 两点之间的最短路径(单源最短路径(Dijkstra算法))
- 某源点到其他各个点的最短路径(所有顶点之间的最短路径(Floyd算法))
7.7.1 Dijkstra算法
单源最短路径
https://www.bilibili.com/video/av36886088/?spm_id_from=333.788.videocard.0(B站)
按路径长度递增次序产生最短路径
7.7.2 Floyd算法
https://www.bilibili.com/video/av36886554/?spm_id_from=333.788.videocard.0(B站)
所有顶点之间的最短路径
算法思想:
- 逐个顶点试探
- 从Vi到Vj的所有可能存在的路径中
- 选出一条长度最短的路径
7.8 拓扑排序
https://www.bilibili.com/video/av36886991/?spm_id_from=333.788.videocard.0(B站)
1.AOV网
用一个有向图表示一个工程的各子工程及其相互制约的关系,其中以顶点表示活动,弧表示活动之间的优先制约关系,称这种有向图为顶点表示活动的网,简称AOV网.
2.AOE网
用一个有向图表示一个工程的各子工程及其相互制约的关系,以弧表示活动,以顶点表示活动的开始或结束时间,称这种有向图为边表示活动的网,简称AOE网.
3.AOV网的特点:
- 若从i到j有一条有向路径,则i是j的前驱;j是i的后继
- 若<i, j>是网中的有向边, 则i是j的直接前驱, j是i的直接后继
- AOV网中不允许有回路, 因为如果有回路存在,表明某项活动是以自己为先决条件,显然这是荒谬的.
- 如何判断AOV网中是否存在回路?
4.什么是拓扑排序?:
在AOV网没有回路的前提下,我们将全部活动排列成一个线性序列,使得若AOV网中有弧<i, j>存在, 则在这个序列中, i一定排在j的前面, 具有这种性质的线性序列称为拓扑有序序列, 相应的拓扑有序排序的算法称为拓扑排序.
5.拓扑排序的方法
一个拓扑序列并不唯一
上述拓扑序列也可以是C9, C10, C11, C6, C1, C12, C4, C2, C3, C5, C7, C8
6.拓扑排序的一个重要应用
检测AOV网中是否存在环
对有向图构造其顶点的拓扑有序序列, 若网中所有顶点都在他的拓扑有序序列中,则该AOV网必定不存在环.
7.9 关键路径
https://www.bilibili.com/video/av36907591/?spm_id_from=trigger_reload(B站)
1.AOE网
用一个有向图表示一个工程的各子工程及其相互制约的关系,以弧表示活动,以顶点表示活动的开始或结束时间,称这种有向图为边表示活动的网,简称AOE网.
2.关键路径
把工程计划表示为边表示的活动的网络, 即AOE网, 用顶点表示事件, 弧表示活动, 弧的权表示活动持续时间
事件表示在他之前的活动已经完成, 在他之后的活动可以开始