四大逻辑结构:集合/线性/树形/图形结构
物理结构
硬盘:文件结构;
内存:顺序结构/链式结构
算法的设计要求:正确/可读/健壮
加密算法
压缩算法:图片、文件、视频
算法效率的度量方法
算法效率的影响因素:1)算法采用的策略和方案;2)编译产生的代码质量;3)问题的输入规模;4)机器执行指令的速度。
引例:计算1到100之和的代码实现
// 第一种算法:共计=2n+2次
int i, sum =0, n =100; //执行1次
for( i=1; i <=n; i++) { //执行了n+1次
sum = sum + i; //执行n次
}
// 第二种算法:共计=2次
int sum =0, n = 100; //执行1次
sum = (1+n)*n/2; //执行1次
结论:
- for循环引入才会引入输入规模n;
- 双重for循环引入n*n,即n2;
引例2:函数的渐进增长
线性增长 | 线性与指数增长 |
---|---|
结论:
- 随着输入规模n的增大,后面的+3和+1其实是不影响最终的算法变化曲线的,所以可以忽略这些加法常数。
- C、D去掉与n相乘的常数前后,线性增长都远小于指数增长,不影响线性增长与指数增长的定性比较结果,也就是说,与最高次项相乘的常数也不重要,也可以忽略。
- 高阶和低阶指数同时存在时,去掉低阶指数,不影响定性比较的结果,也就是忽略掉除最高阶指数外的其他项。
时间复杂度
时间复杂度的原意是衡量一个算法的耗时时间长短,但是我们忽略硬件速度的差异,其实可以蜕变为计算次数的多少。
大O记法
在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。算法的时间复杂度,也就是算法的时间量度,记作: T(n)=O(f(n))。它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称为时间复杂度。其中f(n)是问题规模n的某个函数。
这样用大写O()来体现算法时间复杂度的记法,我们称之为大O记法。一般情况下,随着输入规模n的增大,T(n)增长最慢的算法为最优算法。显然,由此算法时间复杂度的定义可知,引例1的2个求和算法的时间复杂度分别为O(n), O(1)。
大O记法的计算步骤
- 用常数1取代运行时间中的所有加法常数。(去加减)
- 在修改后的运行次数函数中,只保留最高阶项。(只留最高阶)
- 如果最高阶项存在且不是1,则去除与这个项相乘的常数。(去常数系数)
练习1:
int sum = (1+n)*n/2;
// T(n) = 1
// 结果:O(1)
练习2:
int i , n = 100, sum = 0;
for( i=0; i<n; i++) {
sum = sum + i;
}
// T(n) = 1+(1+n) = n+2
// 结果:O(n)
`练习3:
int i, j, n = 100;
for( i=0; i < n; i++ ) {
for( j=0; j <n; j++ ) {
printf( "hello world" );
}
}
// T(n) = 1+(n+1)*(n+1) = (n+1)^2+1
// 结果:O(n^2)
练习4:
int i, j, n = 100;
for(i=0; i < n; i++ ) {
for( j=i; j < n; j++) {
printf( "hello world" );
}
}
// T(n) = 1+n+(n-1)+(n-2)+...+1 = (n+1)*n/2+1
// 结果:O(n^2)
练习5:
int i = 1, n = 100;
while(i<n) {
i =i* 2;
}
// 假设循环次数为x,即:2^x=n, 则T(n)=x=log(2)n
// 结果:O(logn)
常见时间复杂度
术语 | 时间复杂度 | 例子 |
---|---|---|
常数阶 | O(1) | 5201314 |
线性阶 | O(n) | 2n+4 |
平方阶 | O(n^2) | 3n^2+4n+5 |
对数阶 | O(logn) | 3log(2)n+4 |
nlogn阶 | O(nlogn) | 2n+3nlog (2)n+14 |
立方阶 | O(n^3) | n^3 +2n^2+4n+6 |
指数阶 | O(2^n) | 2^n |
常用时间复杂度从小到大依次是:
O(1)
<O(logn)|
<O(n)|
<O(nlogn)
<O(n^2)
< O(n^3)
< O(2^n)
<O(n!)
<O(n^n)
空间复杂度
编写算法时可以用空间来换去时间。举个例子,要判断某年是不是闰年,可以写一个算法,每给一个年份,就可以通过这个算法计算得到是否闫年的结果;另外一种方法是,事先建立一个有2050个元素的数组,然后把所有的年份按下标的数字对应,如果是闰年,则此数组元素的值是1,如果不是元素的值则为日。这样,所谓的判断某一年是否为闰年就变成了查找这个数组某一个元素的值的问题。
算法的空间复杂度通过计算算法所需的存储空间实现,算法的空间复杂度的计算公式记作:
S(n) =o(f(n)), 其中, n为问题的规模, f(n)为语句关于n所占存储空间的函数。
通常,我们都是用“时间复杂度”来指运行时间的需求,是用“空间复杂度”指空间需求。当直接要让我们求“复杂度”时,通常指的是时间复杂度。
线性表
线性表(List):由零个或多个数据元素组成的有限序列。线性表的存储结构有顺序存储和链式存储。
存储结构 | 底层实现 | 操作实现原理 | 特点 |
---|---|---|---|
顺序存储(数组) | 抽象数据类型:sizeof获取单个数据内存大小 maxlength:标记数组最大长度 len:记录当前元素个数 | 增(不考虑扩容):头指针挪动len*sizeof(data),且len++ 插:指针移动到n(i),遍历后面的元素向后移动一个单位长度,n(i)赋值给新元素 读:指针移动到n(i) 删:指针移动到n(i),遍历后面的元素向前移动一个单位长度 | 读增快O(1),插删慢O(n) 需要连续的内存空间,长度固定 |
链式存储(链表) | 节点:数据+指针 len:记录当前节点个数 p:当前指针位置 | 增(尾插法):n(len-1)的next指针指向新节点,且len++ 插:移动指针到节点n(i),新节点next指向n(i+1),再将n(i)的next指针指向新节点 读:头指针移动到n(i) 删:头指针移动到n(i-1),n(i-1)的next指向n(i+1) | 读取慢O(n),而插删快O(1) 可以充分利用零散的内存空间,长度不定 |
顺序存储使用场景:用户个人信息等,一次生成,以后基本都是读取
链式存储使用场景:仓库库存等,频繁增删
除了最基础的单链表,还有:
- 可双向遍历的双链表:Node新增prev指针指向前一个Node;
- 封闭的单向循环列表:尾结点rear指针从NULL改为指向头结点head;
单链表
快指针与慢指针
引例:快速找到未知长度单链表的中间节点。
- 普通的解法:
首先遍历一遍单链表以确定单链表的长度L。然后再次从头节点出发循环L/2次找到单链表的中间节点。
算法复杂度为: O(L+L/2)=0(3L/2) - 使用快慢指针:
利用快慢指针原理:设置两个指针*search
、*mid
都指向单链表的头节点。其中* search
的移动速度是*mid
的2倍。当*search
指向末尾节点的时候,*mid
正好就在中间了。这也是标尺的思想。
Status GetMidNode (LinkList L, ElemType *e)
{
LinkList search, mid:
mid = search =L;
while (search->next != NULL)
{
//search移动的速度是mid的2倍
if(search->next->next != NULL)
{
search = search->next->next;
mid =mid->next:
}
else
{
search = search->next;
}
}
*e =mid->data;
return OK;
}
循环链表
约瑟夫问题
据说著名犹太历史学家Josephus有过以下的故事:在罗马人占领乔塔帕特后,39个犹太人与Josephus及他的朋友躲到一个洞中,39个犹太人决定宁愿死也不要被敌人抓到,于是决定了一个自杀方式,41个人排成一个圆圈,由第1个人开始报数,每报数到第3人该人就必须自杀,然后再由下一个重新报数,直到所有人都自杀身亡为止。
然而Josephus和他的朋友并不想遵从, Josephus要他的朋友先假装遵从,他将朋友与自己安排在第16个与第31个位置,于是逃过了这场死亡游戏。
使用程序输出这41个人的死亡顺序:(思路:使用循环链表)
typedef struct node
{
int data;
struct node *next;
} node;
int main()
{
int n = 41;
int m =3;
int i;
node p = create (n);
node temp;
m =m-1;
while (p != p->next)
{
for (i=1; i <m-1; i++)
{
p =p->next ;
}
printf ("d->", p->next->data );
temp =p->next; //删除第n个节点
p->next = temp->next
free (temp);
p = p->next
}
printf ("d\n", p->data );
return o;
}
判断是否有环
如图不一定是个完美环,只要有环就算
思路一:
使用快慢指针p、q,p每次走2步,q每次走1步。当p为null时,无环;当p=q时,有环;
思路二:
使用p、q两个指针,p总是向前走,但q每次都从头开始走,对于每个节点,看p走的步数是否和q一样。如图,当从6走到3时,用了6步,此时若q从head出发,则只需两步就到3,因而步数不等,出现矛盾,存在环。
魔术师发牌
魔术师利用一副牌中的13张黑牌,预先将他们排好后叠放在一起,牌面朝下。对观众说: “我不看牌,只数数就可以猜到每张牌是什么,我大声数数,你们听,不信?现场演示。魔术师将最上面的那张牌数为1,把他翻过来正好是黑桃A,将黑桃A放在桌子上,第二次数1,2,将第一张牌放在这些牌的下面,将第二张牌翻过来,正好是黑桃2,也将它放在桌子上这样依次进行将13张牌全部翻出,准确无误。 问题:牌的开始顺序是如何安排的?
思路一:
填充一个长度为13,range为[1,13]的整数数组,已知:
- 第一个数是1,有效移动1次,找到位置;
- 第二个数是2,有效移动2次,找到位置;
- 第n个数是n,需要有效移动n次才能找到位置,到后期因为越来越多的位置有数据要略过,不能算一次有效移动;
如此通过for循环,当最后一个数字13找到位置后,退出循环,按题中条件将数组填满就是牌面顺序。——时间复杂度O(n2)。
思路二:
使用循环链表,长度为13,头结点是1, 跟思路一相似,时间复杂度也一样。
拉丁方阵
拉丁方阵是一种n×n的方阵,方阵中恰有n种不同的元素,每种元素恰有n个,并且每种元素在一行
和一列中恰好出现一次。著名数学家和物理学家欧拉使用拉丁字母来作为拉丁方阵里元素的符号,拉丁方阵因此而得名。例如下图是一个3×3的拉丁方阵:
思路:
初始化一个长度为n的循环链表,头结点值为1:
- 第一行:从头结点开始打印,遍历打印每个节点,计数达到n时,打印出来就是第一行;
- 第二行:从第二个节点开始打印,遍历打印每个节点,计数达到n时,打印出来就是第二行;
- 第n行:从第n个节点开始打印…
双向链表
为了弥补单链表读取特性的不足,引入双向链表。
typedef struct DualNode
{
ElemType data;
struct DualNode *prior; //前驱结点
struct DualNode *next; //后继结点
} DualNode, *DuLinkList;
栈和队列
栈
栈(stack)是一种重要的线性结构,是线性表的一种具体形式。其特点是后进先出(FILO),只在表尾进行增删操作。表头称为栈底,表尾称为栈顶,插入称为压栈(push),删除称为出栈(pop)。在生活中,例如我们的浏览器,每点击一次“后退”都是退回到最近的一次浏览网页。
// 这里定义了一个顺序存储的栈,它包含了三个元素: base, top, stacksize.其中
// base是指向栈底的指针变量,
// top是指向栈顶的指针变量,
// stacksize指录栈的当前可使用的最大容量。
typedef struct
{
ElemType *base;
ElemType *top;
int stackSize;
} sqstack;
二进制转十进制
比如,一个二进制:11001001,使用栈存储如下:
#include <stdio.h>
#include <stdiib.h>
#include <math.h>
#define STACK_INIT_SIZE 20
#define STACKINCREMENT 10
typedef char ElemType;
typedef struct
{
ElemType *base;
ElemType *top;
int stackSize;
} sqstack;
void Initstack (sgstack *s);
{
s->base = (ElemType *)malloc (STACK_INIT_SIZE sizeof(ElemType));
if( !s->base)
{
exit (0);
}
s->top = s->base;
s->stacksize = STACK_INIT_SIZE;
}
void Push(sqStack *s, ElemType e)
{
if( s->top-s->base >= s->stacksize)
{
s->base = (ElemType *) realloc(s->base, (s->stacksize + STACKINCREMENT) sizeof(ElemType));
if( !s->base)
{
exit(0);
}
*(s->top) = e;
s->top++; //指针++实际上是移动了一个单位元素长度,同理指针相减等于元素个数
}
void Pop(sqStack *s, ElemType *e)
{
if( s->top == s->base) //指针是用->的,需要写数据时就要传指针
{
return;
}
*e = *--(s->top);
}
int stackLen(sgStack s)
{
return (s.top-s.base); //结构是用.的
}
int main ()
{
Elemтype с;
sqstack s;
int len, i, sum = 0;
Initstack (&s);
printf ("请输入二进制数,输入符号表示结束! \n");
scanf("%c", &c);
while(c!='#')
{
Push($s, c);
scanf("%c", &c);
}
getchar(); //把'\n'从缓冲区去掉
len = stackLen (s);
printf ("栈的当前容量是: \d\n", len) ;
for(i=0; i < len; i++ ).
{
Pop (&s, &c);
sum =sum+ (c-48) *pow(2, i);
}
printf ("转化为十进制数是: \d\n", sum)
return 0;
}
逆波兰表达式
对于(1-2)*(4+5)
,如果用逆波兰表示法(后缀表达式),应该是这样:1 2 - 4 5 + *
。借助这种表达式,结合栈的特点,就可以计算机的性能发挥到极致。
- 数字1、2进栈,遇到减号运算符则弹出两个元素进行运算并把结果入栈,栈中有:-1。
- 同理4、5入栈,遇到加号出栈,结果9入栈,栈中有:-1,9。
- 最后遇到乘法运算符,将9和-1弹出栈进行乘法计算,此时栈空并无数据压栈,-9为最终运算结果!
练习:正常的表达式—>逆波兰表达式
- a+b —> a b +
- a+ (b-c) —> a b c - +
- a+ (b-c)*d—> a b c - d * +
原则:
- 先列数字,忽略加减,遇到括号,才开始处理括号中的操作;
- 乘除优先级仅次于括号;
中缀表达式
我们平时书写的公式就是中缀表达式,比如:(1-2)*(4+5)
。因此把中缀表达式转换为后缀表达式是实现算法的关键一步。以1+(2-3)*4+10/5
为例:
int main()
{
sgStack s;
char c, e;
Initstack(&s );
printf ("请输入中缀表达式,以#作为结束标志: ");
scanf ("용c", &c) ;
while(c!='#')
{
if( c>='0' & c='9')
{
printf ("&c", c);
}
else if( ')'==c)
{
Pop (&s, &e);
while( '('!=e)
{
printf ("c", e);
Pop (&s, &e);
}
}
else if( '+'==c || '-'==c)
{
if(!StackLen(s))
{
Push(&s, c);
}
else
{
do
{
Pop(&s, &e);
if('('==e)
{
Push ($s, e);
}
else
{
printf("%c", e);
}
} while(StackLen (s)&& '('!=e);
Push($s, c);
}
}
else if( '*'==c || '/'==c || '('==с)
{
Push(&s, c);
}
else
{
printf ("\n出错:输入格式错误! \n");
return -1;
}
scanf ("%c", &c);
}
while( stackLen(s) )
{
Pop ($s, $e);
printf("%c", e);
}
return 0;
}
队列
队列(queue)是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。与栈相反,队列是一种先进先出(FirstIn First Out, FIFO)的线性表。与栈相同的是,队列也是一种重要的线性结构,实现一个队列同样需要顺序表或链表作为基础。
队列既可以用链表实现,也可以用顺序表实现。跟栈相反的是,栈一般用顺序表来实现,而队列常用链表来实现,简称为链队列。
typedef struct QNode {
ElemType data;
struct QNode *next;
} QNode, *QueuePrt;
typedef struct {
QueuePrt front, rear; //队头、尾指针
} LinkQueue;
递归和分治思想
引例:兔子在出生两个月后,就有繁殖能力,一对兔子每个月能生出一对小兔子来。假设所有兔子都不会死去,那么一年以后可以繁殖多少对兔子呢?
分析:
- 1月:1对
- 2月:1对
- 3月:1+1=2对
- 4月:2+1=3对
- 5月:3+2=5对
- …(斐波那契数列)
从第三个月开始,每月对数等于前连个月对数之和。
for循环实现斐波那契数列:
int main ()
{
int i;
int a[40];
a[0] =0;
a[1] =1;
printf ("%d %d", a[0], a[1]);
for ( i=2; i < 40; i++)
{
a[i] = a[i-1] + a[i-2];
printf ("ed ", a[i]):
}
return 0;
}
使用递归:
int Fib(int i)
{
if( i< 2)
{
return i == 0? 0: 1;
}
return Fib(i-1) + Fib(i-2);
}
练习:
编写一个递归函数,实现将输入的任意长度的字符串反向输出的功能。例如输入字符串ABC, 则输出字符串CBA。
void print()
{
char a;
scanf( "%c" , &a);
if( a!='#') print();
if( a != '#' ) printf( "%" , a);
}
对于规模已知的问题,迭代(for循环)优先于递归,递归性能瓶颈。
- 穷举法:for循环
- 分治思想:递归
汉诺塔
一位法国数学家曾编写过一个印度的古老传说:在世界中心贝拿勒斯的圣庙里,一块黄铜板上插着三根宝石针。印度教的主神梵天在创造世界的时候,在其中一根针上从下到上地穿好了由大到小的64片金片,这就是所谓的汉诺塔。不论白天黑夜,总有一个僧侣在按照下面的法则移动这些金片:一次只移动一片,不管在哪根针上,小片必须在大片上面。僧侣们预言,当所有的金片都从梵天穿好的那根针上移到另外一根针上时,世界就将在一声霹雳中消天而梵塔、庙宇和众生也都将同归于尽。
分析:
这是一个典型的分治思想的题目,可以通过不断将问题降为更低一级的相同问题,来得到一个递归模型:
- 第一步:先将前63个盘子移动到Y上,确保大盘在小盘下。
- 第二步:将最底下的第64个盘子移动到Z上。
- 第三步:将Y上的63个盘子移动到Z上。
发现第三步是原始问题相同的问题,规模-1,同理可以进一步分解。
//将n个盘子从x借助y移动到2
void move(int n, char x, char y, char z)
{
if(1==n)
{
printf("%c-->%c\n", x, z);
}
else
{
move(n-1, x, z, y); //将n-1个盘子从x借助z移到y上
printf("%c--》%c\n", x, z);
move(n-1, y, x, z); //将n-1个盘子从y借助x移到z上
}
}
八皇后问题
该问题是十九世纪著名的数学家高斯1850年提出:
在8X8格的国际象棋上摆放八个皇后,使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上,问有多少种摆法。
字符串
字符串虽然也可以使用链式存储,但是通常由于其是整体使用,因此一般使用顺序存储,即数组。字符串的比较,通常不关心其大小关系,而是是否匹配,这里有一些比较著名的算法。
BF算法
Brute Force算法,BF算法属于朴素的模式匹配算法,它的核心思想是: 有两个字符串S和T,长度为N和M。首先S[1]和T[1]比较,若相等,则再比较S[2]和T[2],一直到T[M]为止;若S[1]和T[1]不等,则T向右移动一个字符的位置,再依次进行比较。
KMP算法
KMP算法是D.E.Knuth、J.H.Morris和V.R.Pratt的研究结果,大大的避免重复遍历的情况,全称叫做克努特-莫里斯-普拉特算法,简称KMP算法。KMP算法的核心就是避免不必要的回溯,即问题由模式串决定,不是由目标决定。
引例:其中T为模式串,S为目标串
在这个问题中,我们发现:
- s1=t1,s1!=s2
- s2=t2, s2!=s3
- …
- s5!=t5
根据BF算法,此时将T右移一位,从s2开始重新对目标串T进行匹配。但是我们知道了s2=t2,s2!=t1,也就是说这是不必要的回溯,KMP算法就是致力于减少这种不必要的回溯来加快匹配速度。
优化后:遇到s6!=t6,T串右移3位
分析: 当遇到s6!=t6时,说明s1-5和t1-5是相同的。我们关注t1-5这个子串,目标变为移动最小位数使得这个子串出现右侧完全重复的情况,t1=t4, t2=t5,且t4=s4, t5=s5,可以得到t1=s4, t2=s5,所以直接把t1跟s4对齐,也就是右移3位
next数组求解方法:
void getNext( String T, int *next)
{
int j=0, i=1; //j是后缀下标,i是前缀下标
next[1] =0;
while( i<T[0]) // T[0]是字符串长度
{
if( j==0 || T[i]==T[j])
{
i++;
j++;
next[i]=j:
}
else
{
j=next[j];//因为前缀是固定的,后缀是相对的.
}
)
树
树(Tree)是n(n>=0)个结点的有限集。当n=0时成为空树,在任意一棵非空树中:
- 有且仅有一个特定的称为根(Root)的结点;
- 每个节点仅有一个父节点;
- 当n>1时,其余结点可分为m(m>0)个互不相交的有限集T1、T2…Tm,其中每一个集合本身又是一棵树,并且称为根的子树(SubTree)。
节点拥有的子子节点个数称为节点的度(Degree),树的度取树内各节点的度的最大值。度为0的结点称为叶节点(Leaf)或终端结点;度不为0的节点称为分支节点或非终端节点。除根节点外,分支节点也称为内部节点。
结点的层次(Level)从根开始定一起,根为第一层,根的孩子为第二层。树中结点的最大层次称为树的深度(Depth)或高度。
如果树中结点的各子树从左至右是有次序的,不能互换的,则称该树为有序树,否则称为无序树。
森林(Forest)是m(m>=0)棵互不相交的树的集合。对树中每个结点而言,其子树的集合即为森林。
树的存储结构
双亲表示法:
//树的双表示法结点结构
# define MAX_TREE_SIZE 100
typedef int ElemType;
typedef struct PTNode
{
ElemType data; //结点数据
int parent: //双亲位置
} PTTNode:
typedef struct
{
PTNode nodes[MAX_TREE_SIZE]:
int r; //根的位置
int n: //结点数目
} PTrее:
这样的存储结构,我们可以根据某结点的parent指针找到它的双亲结点,所用的时间复杂度是0(1),索引到parent的值为-1时,表示找到了树结点的根。可是,如果我们要知道某结点的孩子是什么,那么就要遍历整个树结构(数组nodes),能不能改进一下呢?
孩子表示法:
PTNode去掉parent,增加链表children属性记录该节点的子节点。
双亲孩子表示法:
PTNode保留parent,增加链表children属性记录该节点的子节点。
#define MAX_TREE_SIZE 100
typedef char ElemType;
//孩子结点
typedef struct CTNode
{
int child; //孩子结点的下标
struct CTNode next; //指向下一个孩子结点的指针
} *ChildPtr;
// 表头结构
typedef struct
{
ElemType data; //存放在树中的结点的数据
int parent; //存放双亲的下标
ChildPtr firstchild; //指向第一个孩子的指针
} CTBох:
//树结构
typedef struct
{
CTBох nodes[MAX_TREE_SIZE]: //結点数组
int r, n
}
实际上树结构的存储结构非常灵活,可以根据实际需求定制,而没有标准格式。
二叉树
二叉树(Binary Tree)是n (n>=0)个结点的有限集合,该集合或者为空集(空二叉树),或者由一个根结点和两棵互不相交的、分别称为根结点的左子树和右子树的二叉树组成。
满二叉树:金字塔型
完全二叉树:右下角可以有残缺的满二叉树,节点序号与满二叉树相同
二叉树的存储结构
-
二叉树的顺序存储结构:
层序遍历,^代表null -
二叉树的链式存储结构:
typedef struct BiTNode
{
ElemType data;
struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;
二叉树的遍历
二叉树的遍历(traversing binary tree)是指从根结点出发,按照某种次序依次访问二叉树中所有结点,使得每个结点被访问一次且仅被访问一次。树的结点之间不存在唯一的前驱和后继这样的关系,在访问一个结点后,下一个被访问的结点面临着不同的选择,因此有不同的遍历算法。
二叉树的遍历方式可以很多,如果我们限制了从左到右的习惯方式,那么主要就分为一下四种:
-
前序遍历
若二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树,再前序遍历右子树。
遍历的顺序为: ABDHIEJCFKG -
中序遍历
若树为空,则空操作返回,否则从根结点开始(注意并不是先访问根结点),中序遍历根结点的左子树,然后是访问根结点,最后中序遍历右子树。
遍历的顺序为: HDIBEJAFKCG -
后序遍历
遍的顺序为: HIDJEBKFGCA -
层序遍历
遍历的顺序为: ABCDEFGHIJK
线索二叉树
树、森林及二叉树的相互转换
赫夫曼树
在数据膨胀、信息爆炸的今天,数据压缩的意义不言而喻。谈到数据压缩,就不能不提赫夫曼(Huffman)编码,赫夫曼编码是首个实用的无损压缩编码方案,即使在今天的许多知名压缩算法里,依然可以见到赫夫曼编码的影子。另外,在数据通信中,用二进制给每个字待进行编码时不得不面对的一个问题是如何使电文总长最短且不产生二义性。根据字符出现频率,利用赫夫曼编码可以构造出一种不等长的二进制,使编码后鲍电文长度最短,且保证不产生二义性。赫夫曼编码正是建立在赫夫曼树之上的。
引例:以下程序在效率上有什么问题呢?
if( a < 60 )
printf("不及格" );
else if( a < 70 )
printf( "及格" );
else if( a< 90)
printf( "良好" );
else
printf( "优秀" );
成绩分布表:
占比70%的及格,却要经过3次if判断,优化后:
if( a >= 90 )
printf("优秀" );
else if( a >= 70 )
printf( "良好" );
else if( a>= 60)
printf( "及格" );
else
printf( "不及格" );
结论:权重高的要优先
把上面两种算法实现抽象为二叉树:
我们先把这两棵二叉树简化成叶子节点带权的二叉树(注:树节点间的连线相关的数叫做权,Weight)。
- 节点的路径长度:从根节点到该节点的路径上的连接数。
- 树的路径长度:树中每个叶子节点的路径长度之和。
- 节点带权路径长度:节点的路径长度与节点权值的乘积。
- 树的带权路径长度:WPL(Weighted Path Length)是树中所有叶子节点的带权路径长度之和。
树 | 树的路径长度 | 树的带权路径长度 |
---|---|---|
左图 | 1+2+3+3=9 | 1×5+2×15+3×70+3×10=275 |
右图 | 3+3+2+1=9 | 3×5+3×15+2×70+1×10=210 |
结论:
- WPL的值越小,说明构造出来的二叉树性能越优。
那么如何构造出最优的赫夫曼树呢?赫夫曼给了我们解决的方案:
已知4个节点及其权重:
在森林中选出两棵根结点的梗值最小的二叉树,合并两棵选出的二叉树,增加一个新结点作为新二叉树的根,权值为左右孩子的权值之和,并不断重复上述步骤:
第一步 | 第二步 | 第三步 |
---|---|---|
赫夫曼编码
赫夫曼编码可以很有效地压缩数据(通常可以节省20%~90%的空间,具体压缩率依赖于数据的特性)。
- 定长编码:使用位数固定的数字表示一个字符,比如ASCII为定长8位
- 变长编码:单个编码的长度可以不一致,可以根据整体出现频率来调节
- 前缀码:所谓的前缀码,就是没有任何码字是其他码字的前缀,比如赫夫曼编码
赫夫曼编码5个步骤:
- build a priority queue
- build a huffmanTree
- build a huffmanTable
- encode
- decode
图
图(Graph)是一种描述多对多关系的数据结构,由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为: G(V, E),其中, G表示一个图, V是图G中顶点的集合,E是图G中边的集合。
基本概念
- 顶点:线性表中把数据元素叫元素,树中叫节点,在图中数据元素我们则称之为顶点(Vertex)。 线性表可以没有数据元素,称为空表,树中可以没有结点,叫做空树,而图结构在国内大部分的教材中强调顶点集合V要有穷非空。
- 边:线性表中,相邻的数据元素之间具有线性关系,树结构中,相邻两层的节点具有层次关系,而图结构中,任意两个顶点之间都可能有关系,顶点之间的逻辑关系用边(Edge)来表示,边集可以是空的。
- 无向边:若顶点Vi到Vj之间的边没有方向,则称这条边为无向边(Edge),用无序偶(Vi, Vj)来表示,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称为孤头,Vi, Vj顺序不可换。
上图G2是一个有向图, G2={V2, E2},其中
- V2-={A, B,C,D},
- E2={<B,A>, <B,C>, <C,A>, <A,D>}
- 简单图:在图结构中,若不存在顶点到其自身的边,且同一条边不重复出现,则称这样的图为简单图。
上面两个图都不属于简单图。
- 无向完全图:在无向图中,如果任意两个顶点之间都存在边,则称该图为无向完全图。含有n个顶点的无向完全图有n*(n-1)/2条边。
上图为无向完全图。
- 有向完全图:在有向图中,如果任意两个顶点之间都存在方向互为相反的两条弧,则称该图为有向完全图。含有n个顶点的有向完全图有n*(n-1)条边。
上图为有向完全图。 - 稀疏图和稠密图:这里的稀疏和稠密是模糊的概念,都是相对而言的,通常认为边或弧数小于n*logn (n是顶点的个数)的图称为稀疏图,反之称为稠密图。
- 网:有些图的边或弧带有与宅相关的数字,这种与图的边或弧相关的数叫做权(Weight),带权的图通常称为网 (Network)。
上图为网。
- 子图:假设有两个图G1=(V1, E1)和G2=(V2, E2),如果 V2 ∈V1, E2∈E1,则称G2为G1的子图(Subgraph)。
左边是右边的子图
关系定义
顶点与边
对于无向图G=(V, E),如果边(V1, V2) ∈E, 则称顶点V1和V2互为邻接点(Adjacent),即VI和V2相邻接。边 (V1,V2)依附(incident)于顶点V1和V2,或者说边(V1,V2)与顶点V1和V2相关联。
顶点V的度(Degree)是和V相关联的边的数目,记为TD(V),如下图,顶点A与B互为邻接点,边(A,B)依附于顶点A与B上,顶点A的度为3。
对于有向图G=(V,E),如果有<V1,V2>∈E, 则称顶点V1邻接到顶点V2,顶点V2邻接自顶点VI。以顶点V为头的弧的数目称为V的入度(InDegree),记为 ID(V),以V为尾的弧的数目称为V的出度(OutDegree), 记为OD(V),因此顶点V的度为TD(V )=ID(V)+OD(V)。
下图顶点A的入度是2,出度是1,所以顶点A的度是3。
无向图G=(V,E)中从顶点V1到顶点V2的路径(Path)。下图用红线列举了从顶点B到顶点D的四种不同路径:
如果G是有向图,则路径也是有向的。下图用红线列举顶点B到顶点D的两种路径,而顶点A到顶点B就不存在路径:
路径的长度是路径上的边或弧的数目。第一个顶点到最后一个顶点相同的路径称为回路或环 (Cycle)。
序列中顶点不重复出现的路径称为简单路径,除了第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路,称为简单回路或简单环。
下图左侧是简单环,右侧不是简单环:
图与图
在无向图G中,如果从顶点V1到顶点V2有路径,则称V1和V2是连通的,如果对于图中任意两个顶点Vi和Vj都是连通的,则称G是连通图 (ConnectedGraph)。下图左侧不是连通图,右侧是连通图:
无向图中的极大连通子图称为连通分量。注意以下概念:
- 首先要是子图,并且子图是要连通的;
- 连通子图含有极大顶点数;
- 具有极大顶点数的连通子图包含依附于这些顶点的所有边。
在有向图G中,如果对于每一对Vi到Vj都存在路径,则称G是强连通图。有向图中的极大强连通子图称为有向图的强连通分量。下图左侧并不是强连通图,右侧是。并且右侧是左侧的极大强连通子图,也是左侧的强连通分量。
连通图生成树:所谓的一个连通图的生成树是一个极小的连通子图,它含有图中全部的n个顶点,但只有足以构成一棵树的n-1条边。
反例 | 正例 |
---|---|
如果一个有向图恰有一个顶点入度为0,其余顶点的入度均为1,则是一棵有向树。
图的存储结构
因为任意两个顶点之间都可能存在联系,因此无法以数据元素在内存中的物理位置来表示元素之间的关系(内存物理位置是线性的,图的元素关系是平面的)。如果用多重链表来描述倒是可以做到,但纯粹用多重链表导致的浪费是无法想像的(如果各个顶点的度数相差太大,就会造成巨大的浪费)。
邻接矩阵
无向图
考虑到图是由顶点和边或弧两部分组成,合在一起比较困难,那就很自然地考虑到分为两个结构来分别存储。
顶点因为不区分大小、主次,所以用一个一维数组来存储是狠不错的选择。
而边或弧由于是顶点与顶点之间的关系,一维数组肯定就搞不定了,那我们不妨考虑用一个二维数组来存储。
图的邻接矩阵(Adjacency Matrix)存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。
图 | 存储结构 |
---|---|
我们可以设置两个数组,顶点数组为 vertex[4]={Ve, V1, V2, V3},边数组arc[4][4]为对称矩阵(0表示不存在顶点间的边,1表示顶点间存在边)。
对称矩阵:所谓对称矩阵就是n阶矩阵的元满足 a[i][j]-a[j][i], (0<=i,j<=n)
。即从矩阵的左上角到右下角的主对角线为轴,右上角的元与左下角相对应的元全都是相等的。
有了这个二维数组组成的对称矩阵,我们就可以很容易地知道图中的信息:
- 要判定任意两顶点是否有边无边就非常容易了;
- 要知道某个顶点的度,其实就是这个顶点Vi在邻接矩阵中第i行(或第i列)的元素之和;
- 求顶点Vi的所有邻接点就是将矩阵中第i行元素扫描一遍, arc[i][j]为1就是邻接点略。
有向图
无向图的边构成了一个对称矩阵,貌似浪费了一半的空间,那如果是有向图来存放,会不会把资源都利用得很好呢?
图 | 存储结构 |
---|---|
可见顶点数组vertex[4]={V ,V1, V2, V3},弧数组arc [4] [4]也是一个矩阵,但因为是有向图,所以这个矩阵并不对称,例如由V1到V0有弧,得到arc [1][0]=1,而V0到V1没有弧,因此 arc[0][1]=0.
另外有向图是有讲究的,要考虑入度和出度,顶点VI的入度为1,正好是第V1列的各数之和,顶点V1的出度为2,正好是第V2行的各数之和。
网
在图的术语中,我们提到了网这个概念,事实上也就是每条边上带有权的图就叫网。
图 | 存储结构 |
---|---|
这里“00”表示一个计算机允许的、大于所有边上权值的值。
邻接表
邻接矩阵看上去是个不错的选择,首先是容易理解,第二是索引和编排都很舒服。但是我们也发现,对于边数相对顶点较少的图,这种结构无疑是存在对存储空间的极大浪费。
图 | 存储结构 |
---|---|
只有一条边,却依然创建了4×4的二维数组,造成存储空间浪费
因此我们可以考虑另外一种存储结构方式,例如把数组与链表结合一起来存储,这种方式在图结构也适用,我们称为邻接表(AdjacencyList)。邻接表的处理方法是这样:
- 图中顶点用一个一维数组存储,当然,顶点也可以用单链表来存储,不过数组可以较容易地读取顶点信息,更加方便。
- 图中每个顶点Vi的所有邻接点构成一个线性表,由于邻接点的个数不确定,所以我们选择用单链表来存储。
无向图
图 | 存储结构 |
---|---|
存储结构要一行一行看:
- 第一行:V0顶点有三个邻接点,分别是V1/V2/V3,其数组下标分别1/2/3
- 其他行同理
有向图
图 | 存储结构 |
---|---|
同样是一行一行看,本表记录的是每个顶点的出度:
- 第一行:V0有1个出度,指向V3,其下标是3;
- 其他行同理
但也有时为了便于确定顶点的入度或以顶点为弧头的弧,我们可以建立一个有向图的逆邻接表:
图 | 存储结构 |
---|---|
此时我们很容易就可以算出某个顶点的入度或出度是多少,判断两顶点是否存在孤也很容易实现。
网
对于带权值的网图,可以在边表结点定义中再增加一个数据域来存储权值即可:
图 | 存储结构 |
---|---|
十字链表
邻接表固然优秀,但也有不足,例如对有向图的处理上,有时候需要再建立一个逆邻接表。有没有可能把邻接表和逆邻接表结合起来呢?答案是肯定的,这就是我们现在要谈的十字链表 (Orthogonal List)。为此我们重新定义顶点表结点结构:
接着重新定义边表结点结构:
图 | 存储结构 |
---|---|
发现相比邻接表,多了一个存储位,一个存储位用来存放出度链表,一个用来存放入度链表。
还是一行一行看:
- 第一行:有入度和出度两个链表,只画了出度链表
- 第二行:有入度和出度两个链表,只画了出度链表
- 其他行同理
十字链表的好处就是因为把邻接表和逆邻接表整合在了一起,这样既容易找到以Vi为尾的弧,也容易找到以Vi为头的弧,因而容易求得顶点的出度和入度。除了结构复杂一点外,其实创建图算法的时间复杂度是和邻接表相同的,因此,在有向图的应用中,十字链表也是非常好的数据结构模型。
提示:如果仅关系入度或者出度之一,那么不用纠结,用邻接表就行了
邻接多重表
针对有向图邻接表的存储优化提出了十字链表,那么无向图的邻接表是否可以优化呢?如果我们在无向图的应用中,关注的重点是顶点的话,那么邻接表是不错的选择,但如果我们更关注的是边的操作,比如对已经访问过的边做标记,或者删除某一条边等操作,邻接表就显得不那么方便了。
图 | 存储结构 |
---|---|
对于上面的无向图而言,若要删除(V0,V2)这条边·就需要对邻接表结构中边表的两个结点进行删除操作。
因此,我们也仿照十字链表的方式,对边表结构进行改装,重新定义的边表结构如下:
其中:
- iVex和jVex是与某条边依附的两个顶点在顶点表中的下标
- iLink指向依附顶点iVex的下一条边, jLink指向依附顶点jVex的下一条边。
图 | 存储结构 |
---|---|
说实话这里没怎么理解,感兴趣的话后面可以自行查资料
边集数组
边集数组是由两个一维数组构成,一个是存储顶点的信息,另一个是存储边的信息,这个边数组每个数据元素由一条边的起点下标(begin)、终点下标(end)和权(weight)组成。
图 | 存储结构 |
---|---|
跟邻接矩阵相比,使用edges数组替代了顶点二位数组
图的遍历
谈到图的遍历,那就复杂多了,因为它的任一顶点都可以和其余的所有顶点相邻接,因此极有可能存在重复走过某个顶点或漏了某个顶点的遍历过程。对于图的遍历,如果要避免以上情况,那就需要科学地设计遍历方案,通常有两种遍历次序方案:深度优先遍历和广度优先遍历。
深度优先遍历
深度优先遍历(DepthFirstSearch),也有称为深度优先搜索,简称为DFS,实现方案上可以采取递归。它的具体思想类似于房屋找钥匙方案,无论从哪一间房间开始都可以,将房间内的墙角、床头柜、床上、床下、衣柜、电视柜等换个寻找,做到不放过任何一个死角,当所有的抽屉、储藏柜中全部都找遍,接着再寻找下一个房间。
我们可以约定右手原则:在没有碰到重复顶点的情况下,分叉路口始终是向右手边走,每路过一个顶点就做一个记号。
广度优先遍历
广度优先遍历(BreadthFirstSearch) ,又称为广度优先搜索,简称BFS。如果以之前找钥匙的例子来讲,运用深度优先遍历意味着要先彻底查找完一个房间再开始另一个房间的搜索。但我们知道,钥匙放在沙发地下等犄角旮旯的可能性极低,因此我们运用新的方案:先看看钥匙是否放在各个房间的显眼位置,如果没有,再看看各个房间的抽屉有没有。这样逐步扩大查找的范围的方式我 称为广度优先遍历。
经典算法题
骑士周游问题/马踏棋盘
国际象棋的棋盘为8*8的方格棋盘,现将“马”放在任意指定的方格中,按照“马”走棋的规则将“马”进行移动。要求每个方格只能进入一次,最终使得“马”走遍棋盘64个方格。编写代码,实现马踏棋盘的操作,要求用1-64来标注“马”移动的路径 。
马的走位:只能斜对角
- 回溯法:
之前我们谈过回溯法,还是那句话,指导思想很简单,就是一条路走到黑,碰壁了再回来一条路走到黑。一般和递归可以很好的搭配使用,还有深度优先搜索(DFS)。 - 哈密尔顿路径:
图G中的哈密尔顿路径指的是经过图G中每个顶点,且只经过一次的一条轨迹。如果这条轨迹是一条闭合的路径(从起点出发不重复地遍历所有点后低能回到起始点) ,那么这条路径称为哈密尔顿回路
最小生成树
方案一:成本: 11+26+20+22+18+21+24+19=161
方案二:成本: 8+12+10+11+17+19+16+7=100
方案三:成本: 8+12+10+11+16+19+16+7=99
普里姆算法
克鲁斯卡尔算法
最短路径
求V0到V8的最短路径
迪杰斯特拉算法
弗洛伊德算法
招扑排序
一个无环的有向图称为无环图(Directed Acyclic Graph) ,简称DAG图。所有的工程或者某种流程都可以分为若干个小的工程或者阶段,我们称这些小的工程或阶段为“活动”
在一个表示工程的有向图中,用顶点表示活动,用弧表示活动之间的优先关系,这样的有向图为顶点表示活动的网,我们称之为AOV网(Active On Vertex Network),AOV网不能存在回路。
拓扑序列:设G=(V,E)是一个具有n个顶点的有向图, V中的顶点序列V1,V2…Vn满足若从顶点Vi到Vj有一条路径,则在顶点序列中顶点Vi必在顶点Vj之前。则我们称这样的顶点序列为一个拓扑序列。
拓扑排序:所谓的拓扑排序,其实就是对一个有向图构造拓扑序列的过程。
引例:学生课表及课程要求
这个表转换为AOV网是这样子:
拓扑序列(其中一种): 1,13,4,8,14,15,5,2,3,10,11,12,7,6,9
对AOV网进行拓扑排序的方法和步骤如下:
- 从AOV网中选择一个没有前趋的顶点(该顶点的入度为0)并且输出它;
- 从网中删去该顶点,并且删去从该顶点发出的全部有向边
- 重复上述两步,直到剩余网中不再存在没有前趋的顶点为止。
由刚才我们那幅AOV网图,我们可以用邻接表(因为需要删除顶点,所以我们选择邻接表会更加方便)数据结构表示。
关键路径
概念:
AOE网:在一个表示工程的带权有向图中,用顶点表示事件,用有向边表示活动,用边上的权值表示活动的持续时间,这种有向图的边表示活动的网,我们称之为AOE网(Activity On Edge Network) 。
我们把AOE网中没有入边的顶点称为始点或源点,没有出边的顶点称为终点或汇点。
关键路径:从源点到汇点权值最大的路径
- 1/2/3这些数字表示状态
- an表示活动,an的值表示活动所需时间
AOV网与AOE网的区别是AOE网是有权值的
引例:
已知汽车厂采用流水线生产,部分产线并行生产,部分产线需前置部件生产好才能进行,且:
- 生产轮子: 0.5天,
- 发动机: 3天,
- 底盘: 2天,
- 外壳: 2天,
- 其他零部件:2天,
- 全部零部件集中到一处: 0.5天,
- 红装成车并测试:2天。
请问汽车产造一辆汽车,最短需要多少时间呢?
关键路径如何建立在拓扑序列上。谷歌、百度、维基以下关键词
- etv(Earliest Time Of Vertex)
- ltv(Latest Time Of Vertex)
- ete(Earliest Time Of Edge)
- lte(Latest Time Of Edge)