前言
一些需要注意的
该篇文章为《数据结构与算法分析 C语言描述》的笔记,可供其他人学习参考,但学习数据结构最重要的不是看,而是自己动手实现。
- 大概内容是书籍的摘要,以及书中课后作业的思路,会挑选一些课后作业中有意思的题目记录。
- 会做到尽量详细,适合初学者浏览,可以了解数据结构大致体系,对每个知识点形成一定的理解。
- 随学习进度不断更新。
- 所有内容均不会超过书籍范围,紧扣数据结构。
- 一些过于简单的定理不会详细写,比如大学之前学到的一些指数对数运算法则。
为什么要写该笔记
首先,笔记的所有内容基于Mark Allen Weiss所著的data structure and algorithm analysis in C,据说这本大黑书风评还可以,因此计划用于自学。
写电子笔记,一方面是受学长们启发,知道可以使用写博客的方式记录自己的学习;另一方面为了督促自己,防止自己开摆。
好,那么笔记正式开始。
第一章 引论
1.1 本书讨论的内容
看书籍的标题就应该知道本书讨论的内容是什么了。因此略过。
1.2 数学知识复习
1.2.1 指数
指数的基本运算。
1.2.2 对数
对数的基本运算与常见定理。
需要注意,在计算机科学中,除非有特别的声明,所有的对数都以2为底。
如下是常见对数:
, , ,
1.2.3 级数
需要了解一些级数的基本知识,比如等比数列的求和公式,平方和公式等。
1.2.4 模运算
如果N整除A-B,那么我们就说A与B模N同余,记为
1.2.5 证明方法
归纳法
- 证明基准情形的正确性
- 进行归纳假设,假设直到k都是成立的,用这个假设证明定理对下一个值(一般是k+1)同样也是成立的。
反证法
通过假设定理不成立,然后证明该假设导致某个已知的性质不成立。
1.3 递归
递归的思想非常重要,需要理解并牢记递归的四条基本法则。
- 基准情形:必须有些基本情况,不需要递归就能求解。
- 不断推进:递归调用必须要朝着产生基准情形的方向推进。
- 设计法则:假设所有的递归调用都能运行。
- 合成效益法则:在求解一个问题的同一实例时,切勿在不同的递归调用中做重复的工作。
第一章总结
第一章的内容并不多,而且较为简单,主要目的是为了让读者回忆相关的基本数学知识、温习一些常见的证明方法与复习递归的思想(该书假设的前提是读者具备C的知识、熟悉指针和递归),因此没有详细笔记。
第一章练习
基本是一些数学推导,主要考察对于级数的理解,大部分是针对数学基础出的题型。
推荐一个有意思的题目。
设是斐波那契数,证明:
a.
b. , 其中
c. 给出封闭形式的准确表达式
前两题用数学归纳法基本上可以完全解决,第三题有些难度,可以构造矩阵,求出其特征值与特征向量来求解(该方法计算量比较大,限于笔者知识浅薄,目前只能想到这种方法)。
第二章 算法分析
算法是为求解一个问题需要遵循的、被清楚地指定的简单指令的集合。确定一个算法所需要的时间与空间是一个非常值得研究的问题。
2.1 数学基础
估计算法资源消耗所需的分析一般是一个理论问题,因此需要一套正式的系统框架。
假设有两个函数和,两个函数之间的增长率有多种关系:
- 两个函数的增长率相等,
- 函数f的增长率大于另一个函数g, ,此时我们通常说g(N)是f(N)的下界。
- 函数f的增长率小于等于另一个函数g, ,此时我们通常说g(N)是f(N)的上界。
- 函数f的增长率小于(不等于)另一个函数g,
不难理解,上面的等式本质上表示的是两个函数增长率之间的不等关系,以下是一些典型的增长率,可供参考。
函数 | c | logN | log^2 N | N | NlogN | N^2 | N^3 | 2^N |
---|---|---|---|---|---|---|---|---|
名称 | 常数 | 对数级 | 对数平方级 | 线性级 | 平方级 | 立方级 | 指数级 |
我们一般需要找到一个像上面表格中差不多的式子,来表示某个函数的增长率。注意,不是仅保证某个式子的增长率大于等于那个函数,而是需要保证式子和函数的增长率相等。这样,我们表示的结果才会尽可能好。
规范且正确地表示函数的增长率,需要掌握以下三个重要结论:
- 如果且,那么有,
- 如果是一个k次多项式,则
- 对于任意常数k,,对数增长非常缓慢
一般而言,我们可以忽略低阶项和常数,比如,要求的精度是很低的。比较两个函数的增长率,可以比较N趋于正无穷的情况下的值,必要时使用洛必达法则比较。
2.2 模型
为了便于纯粹地分析算法,排除现实中的其它非算法的因素,我们假设有一台计算机,它做任意一件简单的工作,都会花费一个时间单元,而且假设它具有无限的内存。
2.3 要分析的问题
现在定义两个函数:
表示算法所花费的平均时间,通常它很难确定,因为平均情况的界计算起来通常较困难。
表示最坏情况下的运行时间,若无特别指定,我们要看的量是最坏情况下的运行时间。
需要知道,一个问题的解决可以采取多种算法,每个算法的解决时间T可能完全不同,随着数据量的增长,某些算法的T可能增长得极为恐怖,某些算法的T则增长得非常缓慢。后者显然是高效的算法。
2.4 运行时间计算
2.4.1 一个简单例子
此处作者举了一个的代码示例,此处不做详细阐述。
2.4.2 一般法则
分析的基本策略,是从内部(最深层部分)向外展开。如果有函数调用,要首先分析调用。如果遇见递归,它可能是一个稍加掩饰的for循环,按照接下来的法则分析即可,它也可能无法转换成循环结构,此时
法则1——for循环
一次for循环的运行时间,至多是该for循环内语句的运行时间乘以迭代次数。
法则2——嵌套for循环
从里向外分析循环,在一组嵌套循环内部的一条语句的总运行时间为该语句的运行时间乘以该组所有for循环的运行时间。
法则3——顺序语句
将各个语句的运行时间求和即可(所得最大值就是所得运行时间)。
法则4——if/else语句
if(Condition)
S1;
else
S2;
一个if/else语句的运行时间可看作判断的时间加上S1和S2中运行时间较长者的总运行时间。
2.4.3 最大子序列和
一个经典问题例子,在一串序列中找出子序列,使得子序列元素的和是最大的。有多种算法可供解决该问题。
算法1
这个算法不怎么值得看,而且和算法2相差不大,因此不展示了。
算法2
int MaxSubSequenceSum(const int A[], int N)
{
int ThisSum, MaxSum, i, j;
MaxSum = 0;
for(i = 0; i < N; i++)
{
ThisSum = 0;
for(j = i;j < N; j++)
{
ThisSum += A[j]
if(ThisSum > MaxSum)
MaxSum = ThisSum;
}
}
return MaxSum;
}
显然是的算法。
算法3
该算法采用了分治的策略,最大子序列和只可能在三处出现,要么在数据左半部分,要么在数据右半部分,要么在中部从而占据左右两部分。前两种情况很好解决,最后一种情况可以通过求出前半部分最大和(包括前半部分最后一个元素)和后半部分最大和(包括后半部分第一个元素),相加求解。
分治需要用到递归的思想,关键在于理解递归的运行,理清楚程序执行顺序。
int max3(int a,int b,int c)
{
if(a>b&&a>c)
return a;
if(b>a&&b>c)
return b;
return c;
}
static int MaxSubSum(const int A[], int Left, int Right)
{
int MaxLeftSum, MaxRightSum;
int MaxLeftBorderSum, MaxRightBorderSum;
int LeftBorderSum, RightBorderSum;
int Center, i;
//此处提供基准情形
if(Left == Right)
if(A[Left]>0)
return A[Left];
else
return 0;
//此处是递归的关键部分,需要理解不断二分的思想
Center = (Left + Right)/2;
MaxLeftSum = MaxSubSum(A,Left, Center);
MaxRightSum = MaxSubSum(A, Cenetr, Right);
//每次递归进行到这里,用于求左侧、右侧、分界线两侧的最大值
LeftBorderSum = 0; MaxLeftBorderSum = 0;
for(i = Center; i>=Left;i--)
{
LeftBorderSum += A[i];
if(LeftBorderSum > MaxLeftBorderSum)
MaxLeftBorderSum = LeftBorderSum;
}
RightBorderSum = 0; MaxRightBorderSum = 0;
for(i = Center+1; i<=Right;i++)
{
RightBorderSum += A[i];
if(RightBorderSum > MaxRightBorderSum)
MaxRightBorderSum = RightBorderSum;
}
//输出最大值
return max3(MaxLeftBorderSum + MaxRightBorderSum, MaxLeftBorderSum, MaxRightBorderSum);
}
int MaxSubsequenceSum(const int A[], int N)
{
return MaxSubSum(A, 0, N-1)
}
花费的时间是
算法4
int MaxSubsequenceSum(const int A[], int N)
{
int ThisSum, MaxSum, j;
ThisSum = MaxSum = 0;
for(j = 0;j < N;j++)
{
ThisSum += A[j];
if(ThisSum > MaxSum)
MaxSum = ThisSum;
else if(ThisSum < 0)
ThisSum = 0;
}
return MaxSum;
}
不难估计时间,但是需要明白算法为何正确。
在任意时刻,算法都能对它已经读入的数据给出子序列问题的正确答案的算法叫做联机算法。
仅需要常量空间并以线性时间运行的联机算法几乎是完美的算法。
2.4.4 运行时间中的对数
如果一个算法用常数时间O(1),将问题大小削减为其一部分(通常会是1/2),那么该算法就是O(log N)的。
另一方面,如果使用常数时间,只是把问题减少一个常数(如将问题减少1),那么这种算法就是O(N)的。
下面给出一些例子,以下算法的运行时间均是logN,看这些算法例子的目的在于加强对logN运行时间的判断。
对分查找
对分查找的数据是已经预先排序的,只需利用二分的思想查找即可。
int BinarySearch(const int A[]; int x; int N)
{
int low,high,mid;
low = 0;
high = N-1;
while(low <= high)
{
mid = (low + high)/2
if(A[mid]>x)
high = mid - 1;
else if(A[mid]<x)
low = mid + 1;
else
return mid;
}
//-1表示没找到
return -1;
}
显然运行时间是
欧几里得算法
unsigned int
gcd(unsigned int m, unsigned int n)
{
unsigned int rem;
while(n > 0)
{
rem = m%n;
m = n;
n = rem;
}
return m;
}
该算法需要大量篇幅的数学分析才能得出平均性能,但最坏性能显然是logN。
幂运算
运行时间也是logN。
long int
pow(long int x, unsigned int n)
{
if(n == 0)
return 1;
if(n == 1)
return x;
if(n%2==0)
return pow(x*x, n/2);
else
return pow(x*x, n/2)*x;
}
2.4.5 检验你的分析
以上内容目的均在于算法的性能估计,如果要检验自己的估计是否正确。
通常计算上式比值,T(N)是观察到的运行时间,f(N)是理想近似,如果估计正确,比值会收敛到一个正常数字。
估计过大,比值收敛为0;估计过小,比值发散。
2.4.6 分析结果的准确性
平均结果很难分析,而且有时会与最坏结果相差较大。
一般我们都采取最坏情形估计。
第二章总结
本章只提供了一些简单的分析方法,用来分析程序的性能。但我们没有提及下界分析,因为它通常较为困难。
复杂的分析,之后会提到。
第二章习题
运行时间分析
void sum1(int n)
{
int sum = 0;
for(int i=0;i<n;i++)
sum++;
}
void sum2(int n)
{
int sum = 0;
for(int i=0;i<n;i++)
for(int j=0;j<n;j++)
sum++;
}
void sum3(int n)
{
int sum = 0;
for(int i=0;i<n;i++)
for(int j=0;j<n*n;j++)
sum++;
}
void sum4(int n)
{
int sum = 0;
for(int i=0;i<n;i++)
for(int j=0;j<i;j++)
sum++;
}
第三章 表、栈和队列
3.1 抽象数据类型
抽象数据类型,Abstract Data Type,简称ADT。它是一些操作的集合,可以看作是模块化设计的一种扩充。
3.2 表ADT
除第一个和最后一个元素之外,其它元素均有前驱元和后继元。
此外,有一些定义的操作,比如查询、插入、删除元素等功能。
3.2.1 表的简单数组实现
一般使用简单数组操作时,插入和删除操作的最坏情况是O(N),运行时间较慢。同时,表的大小还必须事先已知(因为要创建简单数组)。因此简单数组一般不用于实现表这种结构。
3.2.2 链表
链表由一系列不必在内存中相连的结构组成,每一个结构均含有表元素和指向包含该元素的后继元的结构的指针(Next指针)。最后一个单元的Next指针指向NULL。
直观理解可以直接看书中的例子,给出了链表的形象图。
3.2.3 程序设计的细节
现在面临三个问题。
- 很难在表的起始端之前插入元素。
- 从表的起始端实行删除有可能会造成表的丢失。
- 删除算法要求我们记住被删除元素前面的表元。
做一个简单的变化可以解决上述的问题:在表头留出一个空节点,这是一种惯例。
#include <stdio.h>
#include <stdlib.h>
#ifndef _LIST_H
//以下是链表的类型声明
struct Node;
typedef struct Node* PtrToNode;
typedef PtrToNode List;
typedef PtrToNode Position;
//此处将ElementType定义为double,可以随时进行修改
typedef double ElementType;
List MakeEmpty(List L);
int IsEmpty(List L);
int IsLast(Position P, List L);
Position Find(ElementType X, List L);
void Delete(ElementType X, List L);
Position FindPrevious(ElementType X, List L);
void Insert(ElementType X, List L, Position P);
void DeleteList(List L);
Position Header(List L);
Position First(List L);
Position Advance(Position P);
ElementType Retrieve(Position P);
#endif
struct Node
{
ElementType Element;
Position Next;
};
//开始编写链表相关函数
// 1.测试链表是否为空
int IsEmpty( List L )
{
return L->Next == NULL;
}
//链表若为空,返回1,否则返回0
// 2.测试当前位置是否是链表的末尾的函数
int IsLast(Position P, List L)
{
return P->Next == NULL;
}
// 3. 返回某个元素的位置
Position Find(ElementType X, List L)
{
Position P;
P = L->Next;
while (P != NULL && P->Element != X)
P = P->Next;
return P;
}
// 与Delete一起使用的Find例程
//如果删除第一个元素,FindPrevious返回表头的位置
Position FindPrevious( ElementType X, List L )
{
Position P;
P = L;
while(P->Next != NULL && P->Next->Element != X)
P = P->Next;
return P;
}
// 4.链表的删除(只删除首个出现的X)
void Delete( ElementType X, List L )
{
Position P, TmpCell;
P = FindPrevious( X, L );
if( !IsLast( P, L ) )
{
TmpCell = P->Next;
P->Next = TmpCell->Next;
free(TmpCell);
}
}
// 5. 链表的插入例程
void Insert( ElementType X, List L, Position P )
{
Position TmpCell;
TmpCell = malloc( sizeof( struct Node ) );
if( TmpCell == NULL )
printf( "OUT OF SPACE!!!" );
TmpCell->Element = X;
TmpCell->Next = P->Next;
P->Next = TmpCell;
}
以上是一般链表的代码实现。
3.2.4 常见的错误
- 指针错误:要确定程序中指针的指向。
- malloc函数的使用:注意malloc的用法,要为新节点分配空间。
- free函数的理解:free(P);并不是删除指针P,P指向的地址没有变化,但是在该地址中的数据已经没有定义了。
3.2.5 双链表
在节点中附加一个指针域,指向前一个节点。
这样,该节点既有指向前一个节点的指针,又有指向后一个节点的指针。
它增加了空间的需求,简化了删除操作,因为你不再被迫使用FindPrevious获得访问前一个节点的指针,前一个节点的地址是现成的。
至于我们为什么需要前一个节点的地址,是因为删除某个节点,需要前一个节点和后一个节点连接起来。
3.2.6 循环链表
让最后一个单元反过来直指第一个单元,表头可以存在也可以不存在,当然它也可以是双向链表。
3.2.7 例子
比如:多项式中每一项可以储存在一个节点中;基数排序的实现;多重表的实现等
3.2.8 链表的游标实现
有些语言不支持指针,如果需要链表,则使用游标实现法。
要有一个全局的结构体数组,对于该数组中的任何单元,其数组下标用来代表一个地址。
游标实现的思想基本上和指针实现的思想一致。
3.3 栈ADT
3.3.1 栈模型
栈是限制插入和删除只能在一个位置上进行的表。
栈是后进先出的模型,可以想象成弹匣,最后进入的数据,必须最先出栈。
3.3.2 栈的实现
栈的数组实现是最流行的解决方案。
#include <stdio.h>
#include <stdlib.h>
#ifndef _stack_h
typedef double ElementType
struct StackRecord
{
int Capacity;
int TopOfStack;
ElementType* Array;
};
typedef struct StackRecord* Stack;
int IsEmpty(Stack S);
int IsFull(Stack S);
Stack CreateStack(int MaxElements);
void DisposeStack(Stack S);
void MakeEmpty(Stack S);
void Push(ElementType X, Stack S);
ElementType Top(Stack S);
void Pop(Stack S);
ElementType TopAndPop(Stack S);
#endif
#define EmptyTOS (-1)
#define MinStackSize (5)
//以上是栈的数组实现的声明
//栈的创建
Stack CreateStack(int MaxElements)
{
Stack S;
if(MaxElements < MinStackSize)
printf("Size is too small! ");
S = malloc(sizeof(struct StackRecord));
if(S == NULL)
printf("Out of space!!!");
S->Array = malloc(sizeof(ElementType) * MaxElements);
if(S->Array == NULL)
printf("Out of space!!!!");
S->Capacity = MaxElements;
MakeEmpty(S);
return S;
}
//释放栈
void DisposeStack(Stack S)
{
if(S != NULL)
{
free(S->Array);
free(S);
}
}
//检测是否是空栈
int IsEmpty(Stack S)
{
return S->TopOfStack == EmptyTOS;
}
//创建一个空栈
void MakeEmpty(Stack S)
{
S->TopOfStack = EmptyTOS;
}
//元素进栈
void Push(ElementType X, Stack S)
{
if(IsFull(S))
printf("Stack is already full!");
else
S->Array[++S->TopOfStack] = X;
}
//返回栈顶元素
ElementType Top(Stack S)
{
if(IsEmpty(S))
printf("An empty Stack!");
else
return S->Array[S->TopOfStack];
return 0;
}
//从栈弹出元素
void Pop(Stack S)
{
if(IsEmpty(S))
printf("An Empty Stack!");
else
S->TopOfStack--;
}
//给出栈顶元素并且从栈弹出
ElementType TopAndPop(Stack S)
{
if(!IsEmpty(S))
return S->Array[S->TopOfStack--];
else
printf("Empty Stack");
return 0;
}
以上是栈的部分代码实现(基于数组)
3.3.3 应用
括号匹配问题
这一部分的中文翻译令人难以恭维,算法很简单但是被中文翻译描述得比较糟糕,当然,我个人的总结理解可能也很糟糕。步骤如下。
1. 做一个空栈,读入所有字符直到文件末尾。
2. 遇见左括号,立即推入栈,继续读入字符;遇见右括号,执行第3步。
3. 如果遇见右括号时,栈为空,即没有与右括号相匹配的左括号,报错;若前面没有报错,则弹出栈内一个元素,该元素(左括号)与右括号不是对应的同类型括号,报错。
4. 行至文件末尾栈非空,报错。
后缀表达式
使用后缀表达式则不需要事先了解任何计算的优先规则。
中缀表达式(我们平常熟悉的表达式)可以转换成后缀表达式。
需要掌握:
- 后缀表达式的计算
- 中缀表达式转化为后缀表达式的规则
均基于栈的思想实现。
函数调用
函数调用和函数返回的思想很大程度上类似于开括号和闭括号。
当前环境是由栈顶描述的,函数返回语句的功能是返回前面的环境,在实际计算机中的栈常常是从内存分区的高端向下增长,而在许多的系统中是不检测溢出的。
在一些不检测栈溢出的语言和系统中,主程序若出现栈溢出的情况,会产生一些无意义的指令,并在这些指令被执行时程序崩溃;如果数据部分出现栈移除,当你将一些信息写入数据时,这些信息会冲毁栈的信息(很可能是返回地址),那么程序就会返回到奇怪的地方导致程序崩溃。
递归失控会导致栈移除(因为函数环境不断创建),造成这种情况的原因一般是没有回归到基准情形。
尽量不要使用尾递归(最后一行才进行递归调用)。
3.4 队列ADT
3.4.1 队列模型
队列的插入在一端进行,删除在另一端进行。
3.4.2 队列的数组实现
队列的链表实现过于简单,此处阐述数组实现。
#ifndef _Queue_h
struct QueueRecord;
typedef struct QueueRecord* Queue;
typedef double ElementType;
int IsEmpty(Queue Q);
int IsFull(Queue Q);
Queue CreateQueue(int MaxElements);
void DisposeQueue(Queue Q);
void MakeEmpty(Queue Q);
void Enqueue(ElementType X, Queue Q);
ElementType Front(Queue Q);
void Dequeue(Queue Q);
ElementType FrontAndDequeue(Queue Q);
#endif
struct QueueRecord
{
int Capacity;
int Front;
int Rear;
int Size;
ElementType* Array;
};
//队列是否为空
int IsEmpty(Queue Q)
{
return Q->Size == 0
}
//构造空队列
void MakeEmpty(Queue Q)
{
Q->Size = 0;
Q->Front = 1;
Q->Rear = 0;
}
//入队的例程
static int Succ(int value, Queue Q)
{
if(++value == Q->Capacity)
value = 0;
return value;
}
void Enqueue(ElementType X, Queue Q)
{
if(IsFull(Q))
{
printf("Full queue!");
return NULL;
}
else
{
Q->Size++;
Q->Rear = Succ(Q->Rear, Q)
Q->Array[Q->Rear] = X;
}
}
第三章总结
表、栈和队列是最基本的数据结构,用途广泛。需要理解这些线性表的基本操作。
写到这里我决定改变一下策略,习题大部分的答案并不完整,写了也不知道自己到底是不是写对了,因此后面的笔记主要针对书中的正文内容。
那么这就是第一部分。