目录
一、前言
二、线性表的顺序存储结构
1、概念
2、基本算法的实现
3、表的合并
三、线性表的连式存储结构
1、概念
2、基本算法的实现
3、循环链表及实现
4、双向链表及实现
四、项目实战——会员卡管理系统
1、数据结构的设计
2、模块化功能设计
***********************************************
****************************************正文*****************************************
一、前言
关于博文:
建立博客以来也有6个月了,文章也没写多少,最近也好久不碰了- -,不知不觉大二开始了,前一阵子心血来潮想重拾写博客的习惯
,因此现在开始努力,希望不会中途流产,为此特立此段求监督
。
大二上学期一门非常重要的课程就是数据结构,因此这个假期我的主要精力都在这上面,虽然现在还没有开课,不过对于前面几章也算有一定认识了,毕竟这些都属于基础的数据结构。因为本人用的书也是严蔚敏的数据结构,虽然经典,但对于初学者而言有一个最大的问题——无法实现。学编程最大的痛苦就是只听不练,和没学一样。为此我在这里对《数据结构(c语言版)》(严蔚敏)一书中的大部分伪代码做了简单实现,放在博客上与大家交流。
关于源码:
本系列博文的源码均为C语言实现,不过由于本人大一期间加入实验室后分属ios方向,所以我用的开发工具是Xcode,源码也是Xcode下写的,win平台下可以用普通的记事本等来打开查看,为此带来的不便深表歉意。
适合人群:
该系列博客以初学者学习交流为目的,当然也真诚地感谢大神的不吝赐教,如对博客有疑问、异议或建议,欢迎评论留言,本人绝对一一回答。
关于数据结构的个人看法:
我相信大家和我一样,对数据结构这门课并不是真正的了解。
首先,大家一定会问到的一个问题是——数据结构怎么用?谈到数据结构,大家首先想到的是那复杂的树、图甚至算法导论里BT的各种AVL。其实,正如我一学长说的那样,我们从接触编程开始就一直在用数据结构。C语言中的数组应该算是最早的了,其实数组就是马上要讲得线性表的一种(而且是顺序存储),其他语言中的String、Dictionary、Set等都是已经封装好的数据结构。而我们要做的就是学习一些经典的数据结构然后学会自己在需要是设计结构来解决问题。可以参考一下本文最后的实战,在实战中我们自己就问题建立数据结构,一个好的数据结构是解决问题必不可少的。
其次,大家(包括我在内)一直想问的一个问题一定是——数据结构有用吗?昨天看到csdn的ios专栏有人问这个问题,有的回答这是基础,有个回答没用- -,还有一位说必须要理解掌握,不过做app层的可能并不是特别需要。在这里我自己没资格发表评论,不过我希望与大家共勉一下吧,希望我们都能抱着“数据结构很重要,一定要认真学好”这样的态度去学习。具体对我们自己有没有用,以后亲身体验过不就知道了,真理就是——学了一定没有坏处
---------------------------------------------------分割线--------------------------------------------------
二、线性表的顺序存储
1、概念
(该小标题虽然叫概念,但我不会放一些书上的定义,而是尽量将我自己的理解用正确易懂的形式表现出来)。
刚才也提到过,从C语言以来接触的数组其实就是一种顺序存储的线性表。
什么叫顺序存储?在C中,大家都知道,如果对int数组a[n]来说,如果a[0]的地址为p,那么a[1]的地址一定为(p + sizeof(int)),也就是p + 4。这说明数组的每个元素是“手拉手连着的”而不是分散的,像这样的存储方式即为顺序存储。以后基本上每个数据结构都会有顺序和链式两个存储形式,其本质都是一样的。
2、基本算法的实现
在给出基本算法之前先规定好一些常量
#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE 0
#define OVERFLOW -2
#define LIST_INIT_SIZE 100 //初始分配量
#define LISTINCREMENT 10 //线性表存储空间的分配增量
typedef int ElemType;
typedef int Status;
typedef struct
{
ElemType *data; //数据域存储空间基址,其类型可以根据需求改变
int length; //当前真正的长度
int listSize; //当前分配的容量
}SqList;
然后再做一下规定,因为c语言数组从0开始,但是我们一般说起来从不说“第0个元素”,所以我们让我们的数据从数组的1号开始,即第i个元素对应array[i-1]。
1)表的建立。构造一个空的线性表
思路:通过malloc函数动态分配一段内存空间,将表长置0,容量为初始大小。
代码:
Status InitList_Sq(SqList *L)
{
L->data = (ElemType *)malloc(LIST_INIT_SIZE * sizeof(ElemType));
if (!L->data) {
exit(OVERFLOW); //存储分配失败
}
L->length = 0;
L->listSize = LIST_INIT_SIZE;
return OK;
}
对于这种存储结构而言,“取下标为i的元素”等操作便非常容易了(L->data[i - 1]),我们把重点放到数据的插入与删除上去。
2)表的插入
思路:①位置的合理性,可以想象一下,对C数组而言哪些位置是合理的,在我们规定1号开始的前提下又该怎么写。
②插入元素后数组里多存了一个元素,有没有存满还要插入的情况,这种情况下该怎么写。
③如何插入——对于顺序结构而言,想象我们玩扑克,抽牌时插入的时候有什么特点。
代码:
//在表L中第i个元素之前插入元素e
Status ListInsert_Sq(SqList *L, int i, ElemType e)
{
ElemType *newBase; //当空间已满时该指针记录新分配的空间基址
ElemType *p, *q;
//插入位置不合法
if (i < 1 || i > L->length + 1) {
return ERROR;
}
//当前存储空间已满,增加分配
if (L->length >= L->listSize) {
newBase = (ElemType *)realloc(L->data, (L->listSize + LISTINCREMENT) * sizeof(ElemType));
if (!newBase) {
exit(OVERFLOW); //存储分配失败
}
L->data = newBase;
L->listSize += LISTINCREMENT;
}
//q为插入位置
q = &(L->data[i - 1]);
//所有插入位置之后的元素后移
for (p = &(L->data[L->length - 1]); p >= q; p--) {
*(p + 1) = *p;
}
*q = e;
L->length++;
return OK;
}
重点在于,对于顺序结构而言插入元素之前后面的元素必须依次后移(可见其不便性)。删除操作同理,在做好合理性判断后,删除之后应该将相应元素依次前移
3)删除操作
思路:①合理性检验,这次还和插入时的检验标准一样吗?
②删除前后元素之间的位置关系变化如何?
代码:
//在表L中删除第i个元素并用e返回其值
Status ListDelete_Sq(SqList *L, int i, ElemType *e)
{
ElemType *p, *q;
//删除位置不合法
if (i < 1 || i > L->length) {
return ERROR;
}
p = &(L->data[i - 1]); //p为被删除元素的位置
*e = *p; //被删除元素赋值给e
q = L->data + L->length - 1; //表尾元素的位置,其中L->data是数组的首元素地址
//被删元素之后的元素前移
for (p = p + 1; p <= q; ++p) {
*(p - 1) = *p;
}
L->length--;
return OK;
}
3、顺序表的合并
以上仅仅实现了三个基本算法,不过也是每本书必定会讲的算法。其他的可以自行实现,并不复杂。这里实现一个小小的应用,按序合并两张表。
思路:对比一下体育课上两支队伍的合并,老师(你)派两个同学(指针)依次去问每一位同学身高(遍历),并且每一步只问一位同学。然后两个同学报上来的身高中哪个高就让相应的同学来新的队伍,然后报出高数的这位同学(指针)可以去问下一个人的身高(指针后移),而另一个同学(指针)在本轮中不能动。最后如果有一支队伍没有人了(两个指针中有一个指空),那就将另一支队伍的所有人依次汇到新队伍中。说着可能比较啰嗦,但思路还是比较清晰的。具体实现可以试着写一下。
代码:
//已知顺序线性表La和Lb按值非递减顺序排列
//将两个表合并成第三个表Lc并让Lc的值也按非递减顺序排列
void MergeList_Sq(SqList La, SqList Lb, SqList *Lc)
{
//简化代码用
ElemType *pa, *pb, *pc;
//分别指两个表的末尾
ElemType *pa_last = La.data + La.length - 1;
ElemType *pb_last = Lb.data + Lb.length - 1;
pa = La.data;
pb = Lb.data;
//Lc的大小为之前两表长度之和
Lc->listSize = Lc->length = La.length + Lb.length;
pc = Lc->data = (ElemType *)malloc(Lc->listSize * sizeof(ElemType));
if (!pc) {
exit(OVERFLOW); //存储分配失败
}
//合并时大元素的一方指针后移,小的一方不动。
while (pa < pa_last && pb < pb_last) {
if (*pa <= *pb) {
*pc++ = *pa++;
} else {
*pc++ = *pb++;
}
}
//以下两者仅可能出现一种
while (pa <= pa_last) {
*pc++ = *pa++;
}
while (pb <= pb_last) {
*pc++ = *pb++;
}
}
----------------------------------------------分割线--------------------------------------------------
三、顺序表的链式存储
1、概念
之前说过顺序存储是“手拉手相连接”的存储,即相邻元素的内存地址也相邻,那么链式存储怎么理解呢?
老规矩,在将链式存储之前先来一个类比。看过悬疑片的同学一定知道这样一个情节:有人将你困在一个岛上,给你一张地图碎片,要求你按照这张地图走到一个地方拿到下一个碎片,依次直到拿到最后一张你就能出去了。什么意思呢,起初你只知道一个地图碎片上的信息,其余的一概不知道,但是你有的是下一个地方的位置信息。同时重要的是,这些地方并不一定是连着的,有可能是有人想要戏耍你,第一个地方是东北边,第二个地方变成了西南边……
以上可以得出链式存储结构的特点:①一个“头结点”即可代表一个表。②元素之间位置毫无关联,但一个元素存储着下一个元素的地址(指针域)。
2、基本算法的实现
同样,在给出算法之前定义好我们的一些常量
#define ERROR 0
#define OK 1
#define TRUE 1
#define FALSE 0
typedef int ElemType;
typedef int Status;
/**线性表的单链表存储结构*/
typedef struct Node
{
ElemType data;
struct Node *next;
} Node;
typedef struct Node *LinkList;
以上为链式存储结构中得常量定义。其实这里可以改进的地方很多,一会再说。
由于链式存储结构比较重要,并且有些功能并不是想象中的那样简单,所以这里会将大部分算法进行实现(见附件里代码,有实现有测试函数),轻讲解重实现(其实是偷懒)。
列一下要实现的算法:
/**用e返回L中第i个数据元素的值*/
Status GetElem(LinkList L, int i, ElemType *e);
/**在L中第i个位置之前插入新的数据元素e, L的长度加1*/
Status LinkListInsert(LinkList *L, int i, ElemType e);
/**删除L的第i个数据元素,并用e返回其值,L的长度减1*/
Status LinkListDelete(LinkList *L, int i, ElemType *e);
/**随机产生n个元素的值,建立带表头结点的单链表L(头插法)*/
void CreateLinkListHead(LinkList *L, int n);
/**随机产生n个元素的值,建立带表头结点的单链表L(尾插法)*/
void CreateLinkListTail(LinkList *L, int n);
/**单链表的整表删除*/
Status ClearLinkList(LinkList *L);
/**打印整个链表*/
void PrintLinkList(LinkList L);
讲解一下建表和插入删除。
1)建表。可以看到建表写了两个函数:头插法与尾插法,这是两种重要的建表方法,对应元素添加顺序不同,有点栈和队列的意思(提前提一下而已)。一般来说两种方法无所谓好坏,各有用处,因需求而定,在最后的项目中可以看到尾插法建表应用。
这里只附上头插法,其余见附件。
代码(头插法建表):
void CreateLinkListHead(LinkList *L, int n)
{
Node *p;
int i;
srand((unsigned)time(0)); //初始化随机数种子
*L = (LinkList)malloc(sizeof(Node));
(*L)->next = NULL; //先建立一个带头结点的单链表
for (i = 0; i < n; i++) {
p = (LinkList)malloc(sizeof(Node)); //生成新结点
p->data = rand() % 100 + 1; //随机生成100以内的数字
p->next = (*L)->next;
(*L)->next = p;
}
}
为了方便测试,在建表函数中就给表添加上假数据了,实际情况时可以根据需要添加自定义数据。
2)插入与删除。由于在连式存储结构中,元素与元素的位置并无关系,所以链表的插入删除弥补了顺序表插入删除的移动元素的缺点。代码非常简练。
附插入算法:
Status LinkListInsert(LinkList *L, int i, ElemType e)
{
int j = 1;
Node *p, *s;
p = *L;
while (p && j < i) { //寻找第i个结点
p = p->next;
j++;
}
if (!p || j > i) {
return ERROR; //第i个结点不存在
}
s = (LinkList)malloc(sizeof(Node)); //生成新结点
s->data = e;
//注意,接下来两步顺序不能换
s->next = p->next; //先将p的后继结点赋给s的后继
p->next = s; //再将s赋给p的后继
return OK;
}
思考:为什么最后两步的顺序不能换?
3、循环链表的实现
在上述创建链表时,可以看到最后一个元素的next指空,也就是表示表尾。如果我们不让其指空,而是让其指向头结点,那么在很多时候会非常方便。循环链表最典型的一个应用:约瑟夫环。每个人都应该去实现一下,这一个应用就把循环链表的所有操作基本上都包括在内了。
循环链表的操作大致跟单链表一样,只是最后判断是否到最后一个元素的时候应该用head代替NULL。
这里讲一下书上没有伪代码的一个应用——合并两个循环链表
思路:其实书上给出了一个更好的方法:用尾指针代替头指针,这样合并将非常方便,不过头指针也能实现,怎么合并呢?其实只要将第一个链表的最后一个结点的next指向第二个表,然后将第二个表的最后一个结点的next指向第一个表就行。
代码:
Status UnionTwoCycleLinkLists(LinkList *L1, LinkList *L2)
{
if (*L1 == NULL || *L2 == NULL) {
return ERROR;
}
Node *p = *L1, *q = *L2;
while (p->next != *L1) { //找到l1的尾结点
p = p->next;
}
while (q->next != *L2) { //找到l2的尾结点
q = q->next;
}
p->next = (*L2)->next; //将l2的第一个结点(不是头结点)赋给l1的尾结点的next
q->next = *L1; //将l1的头结点赋值给l2的尾结点的next
return OK;
}
没有尾指针那我们就去找。不过这样的话代码的时间复杂度将为O(max(La.length, Lb.length)),如果用尾指针的话时间复杂度为O(1),从这里也可以看出好的数据结构带来的优势。
4、双向链表的实现
还是那些地图碎片。现在你突然发现上一个地点隐藏着出口,只要回到上一个地方就行了,可是该怎么回去呢?你现在除了当前碎片(结点)外,只有那个第一个碎片(头指针)了,得,沿着头指针给出的路线重新来一遍吧。
单链表是个非常“笨”的结构,它只能往后走,走过一个结点后前一个结点接着就被忘掉了。这样要找PriorElem的话就只能从头开始了。为了克服这一缺点,我们可以建立双向链表。
顾名思义,双向链表就是在结点中添加两个指针域,一个指next, 一个指prior,这又是一个典型的“以空间换时间”的做法,这样许多操作将会非常方便。当然实现起来也略显复杂,不过细心操纵好这些指针还是不难的。
虽然双向链表是最麻烦的,不过到此大家对线性表的操作应该比较熟悉了,可以试着自己写一下双向链表的建表、插入与删除。其实就是多操纵两个或多个指针而已,画着图写并不难实现。可以参考附件给出的实现。
5、静态链表
静态链表是为那些没有指针的语言而写的链表,操作略显复杂,在此仅将实现附在附件中,供感兴趣的人参考。
----------------------------------------------------分割线-------------------------------------------------
四、项目实战——会员卡管理系统
1、数据结构设计
该项目的要求是管理VIP帐户的添加、删除以及每一个账户的交易记录的添加删除等操作。如上图,我们可以设置两种结点,账户结点和交易记录结点。账户结点中除了数据域以外有两个指针域,一个指向下一个账户,一个指向该账户的交易记录表的头结点。交易记录结点包含数据域和指向下一个结点的指针域。
这里我们将数据域单独设为一个结构体,目的在于保存——在保存到文件时,如果将整个结点都保存进去,会占用很多不必要的空间,也就是这些指针域是没必要存进去的。因此我们只保存数据域即可。
在写文件时不仅写账户的数据域(姓名、ID、余额),还要写账户的交易记录条数以及每个交易记录的数据域(存or取、金额、时间)。读的时候也应该读取这三点。(记录条数正是为了保证账户与交易记录的正确对应)。
2、模块化功能设计
这里一共要实现的功能有9个(包括退出程序),分为以下几个函数
void ReadDataFromFile();
void WriteDataToFile();
void CreateUser();
void DestoryUser();
void Query(QueryType type);
void Deposit();
void Withdraw();
其中QueryType为自定义的enum类型,目的是增强可读性。
同时定义了几个“私有方法”便于边写代码和调试。
p_user_node FindUser(char userID[]);
void DeleteAllTradeLogs(user_node node);
void PrintUserTitle();
void PrintUserInfo(user_info info);
void PrintTradeLogTitle();
void PrintTradeLogInfo(trade_log log);
通过这个项目,我们可以将之前的线性表的基本操作巩固,理解数据结构对程序设计的作用,体会模块化设计思想。希望对大家有所帮助。
到此我的第一篇Head First 数据结构 系列也算写完了,希望以此来开一个好头。让我们大家一起把数据结构这块硬骨头给拿下。
附上代码: