主题楼里的内容,不是很切题,扯了很多东西(所以如果喜欢直接看代码和干货的朋友请直接看二楼.......)不过我还是写了个人一些开始使用算法,结构 的 体会和心得。
对于算法和数据结构,常常有两种极端看法。 一种是觉得非常高深,难以触及,比如很多的算法书,一开篇就是一堆很复杂的计算分析。而对于毕业很多年的我们,不管你当年高数复变很高分还是和我一样刚刚合格,可能很多人都是很难看懂那些分析。但实际上,对我们中的很多人,我们更多的时候是需要去学习一种已经被设计出来的算法或者数据结构——我们中有多少人是打算去设计算法的呢?如果不是,至少是不是先学会如何在已有的方案里挑选合适的结构,算法,而且还需要稍作修改,以适应在自己面对的问题里使用呢?
——比如 现在提到的链表,他的“标准”实现就是需要 malloc/free的,但在单片机,尤其是裸机程序中,我们通常被告诫尽量不要使用malloc/free函数,所以,我们需要另一种折中方案,用一个静态数组作为空间,然后通过对数组下标的计算操作模拟 malloc/free这对动态分配函数。一般这种方案被称为 游标实现。除了需要预先预留一个足够大的数组以外,它的使用和语境,和标准链表实现完全一模一样——我的意思是,你可以通过只改写这两个函数,然后直接套在 原来的 链表API里就可以了。
另一种,是觉得某些基础结构 很简单,实现起来又麻烦,干脆就不用了。比如 三种最基础的数据结构,链表 队列 栈。它们的概念确实非常简单,但是实际上,如果我们真的完全理解了它的用途,并妥善使用,那其实还是会发现它们虽然简单,却威力无比。
但从我们看到的一些实现,我们经常会发现,他们对这些 数据结构 的理解,其实是有偏差的,甚至有的基本都错了。
比如我自己,我第一个真正开始大量使用数据结构是 队列,我把它用在 串口接收上。但直到我对着书去写,我才发现我以前从来没有理解 队列的本质特点就是 通过移动 头 和 尾 来避免直接移动数据本身,以保证它的操作时间可以被控制在一定的范围内(数学不好)。
另外一点要强调的是,即使是同一个结构,算法,不同的人都会有不同的出发点和定义的方式。
而我个人对此的态度是,我会选择一个大家比较公认而且我自己也觉得挺不错的 定义,然后尽全力去试图理解他的出发点,把并按它的定义严格完成实现。由于选择看书的原因,我看的是 Mark Allen Weiss(以后简称 M.A.K)的 C语言描述 这本书,我觉得他的定义和(部分)实现 都相当的标准的感觉,而且在我几经使用后都发现他的定义确实很有道理,所以基本上都以他的定义为实现版本。
简单解释一下 前驱元 这个概念,它来自于中译本。意思是指 上一个元素。
简单的说
如果 装着X的表元的Next指向 装着Y的表元的地址,那么,我们就称 表元 Y的前驱元是 X
- /*
- 这是一个可以使用标准 malloc/free的版本
- */
- struct node;
- typedef struct node Node;
-
- typedef Node *pNode;
- typedef pNode List;
- typedef pNode Position;
-
- typedef char ElementType ;
-
- // 判断链表是否空
- int IsEmpty(List L);
-
- // 判断是否表尾
- int IsLast(Position P,List L);
-
- // 寻找链表中是否有某个元素,并返回其位置
- Position Find(ElementType X,List L);
-
- // 寻找元素的前驱元:删除函数用
- Position FindPrevious(ElementType X,List L);
- // 删除 链表中某个元素
- void Delete(ElementType X,List L);
-
- // 在某个位置插入元素X
- void Insert(ElementType X,List L,Position P);
-
- // 删除链表
- void DeleteList(List L);
-
- // 获得链表头位置
- Position Header(List L);
-
- // 获取链表首元素
- Position First(List L);
-
- // ??
- Position Advance(Position P);
-
- // 检索位置P的元素
- ElementType Retrieve(Position P);
-
- struct node
- {
- ElementType Element;
- Position Next;
- };
-
- // 面向调用者,可能需要增加的API
-
- /*
- MakeEmpty()在书里没看到实现,下了答案也没有找到。依我网上看到的理解,这个相当于
- 创建链表的API。所以我干脆换个更直接的名字 CreateList;
- */
- // 创建链表
- List CreateList(void);
本帖最后由 辛昕 于 2014-12-24 23:39 编辑
现在说点实在的。
链表的概念很简单。
但为了更好地说明它,我觉得以普通数组来对比是一种不错的方式。
普通数组,是一组相同元素(比如char int 或者 double型数据)的连续排列,它们在内存上是连续分布的,我们可以直接通过 0,1,2,3.....去访问它的每个元素,专业的术语叫 遍历。
链表 其实 是 从属于 表 这个概念之下的一个子概念。
表 代表的是一组元素以一种特定顺序排列起来的集合。
我们会发现,普通数组 是 表的一种最直观的表达。
但是,实际使用中,在某些情况下,我们会发现,普通数组有很多的缺陷:
举个例子,如果我们要在一个表中某个位置插入一个新的元素,我们就必须移动这个位置后面的所有元素,往后挪一个位置,然后才能放下新元素。
可以理解,这是一种很花时间的操作。
链表提供了一种解决这个问题的思路;
那就是表元不连续排列,而是通过一个附带单元,记录下一个表元的位置。因此我们在插入新表元的时候,只需要交换一下指针,就可以完成插入动作。
是的,链表的想法就是这么简单(它的实现也非常简单,居然不到100行)。
我真正把链表用到单片机上,是在做 多级菜单的时候,但因为此前的实现受制于时间和原有项目的遗留代码影响,我在做的时候未能果断的采取链表。
——这两天终于发现为了增加一些新特性,最终我还是要用链表实现一次。
所以暂时,就不把这个实际例子贴上来(但随后,这个实现我要用在 手机DIY的 12864实现 界面上,届时我会单独开一个帖子把它的完整方案写个应用笔记。)
这里,我采用一个相对简单的 类似于 服务器/客户机 带连接通信时,服务器维护链接列表的 简单例子,作为实例说明。
完整的应用情形以及 链表实现均见下面的帖子。
链表应用实例之 主机维护链接方列表
虽然我曾经在PC机上写过一个简单的 socket套接字程序,利用多线程实现 一个服务器 响应 多个客户端 请求连接,当时做的是 tcp套接字,是带链接的。
但是,当时因为只是个小示例程序,而当时的我也远没能理解到 链表的用途。所以当时我并没意识到这个地方是一个使用链表的最简单例子。
直到后来我在做CC2530透传时。 当时我们是买了现成的zigbee透传模块。并不需要我自己做。测试我们是这样做的,一个主节点链接多个从节点,并随意断开连接的节点,再随意建立新链接的时候,我们发现采购的模块有一些奇怪的表现:
1.主机A 与从节点 a b c d依次建立了链接,然后断开 b,再试图连接b,却发现无法链接。
2.但如果是断开d,再链接d却是可以的;
3.还有更奇葩的。如果断开了c,想再链接c当然还是不行,但假如再断开d,然后先连d,再连c,却是可以的。
我们的同事与厂方打电话沟通,得到的却是语焉不详的解释,以及后来这些模块我们没有采用......这些事情就都是后话了。
但我当时的直觉就是,因为他没有采用合理的方式来维护链接的节点地址(维护就是,当建立连接的时候,要记录链接产生的地址,撤销时,要双方同时撤销,并遗弃原地址。双方有一方遗漏,都无法再次进行连接)。它肯定是用了某种很蹩脚的,或者使用了普通数组记录标志位的方式查询,然后因没有彻底测试通过,而出现这种奇特的BUG。
为了让大家更好理解这个问题,我简单讲一下 CC2530链接的情况。
虽然当时我们用的zigbee无线通信,但是就 通信方式,其实和 标准的 C/S(客户机服务器)模式是一模一样的。因为要建立的是带链接的通信。所以在真正开始传输数据以前先要建立连接——这当然也是通过传输数据,经过确认回复来实现。
建立了链接后,建立链接的双方,都会得到同一个 编号,这个编号本身是随机的,(当然实际实现中,它表现得就像建立链接的顺序一样)。此后,每一次主从节点通信都通过这个编号,类似于地址一样进行匹配和互相传输数据;
同理,不管是主机方还是客户端都可以要求撤销链接。这里我们简单点,以客户端要求撤销为例子。一旦撤销,这个编号将不能再次使用——即使下次再和这个节点连接,产生的编号也不一定就是这个编号,简单的说,这是一个过期地址。
现在回到我此前描述的情形。所谓的 主机方要维护链接列表,它的实质要求就是。
主机方会不断收到突发的客户端要求链接以及撤销链接。而主机方需要动态的维护一张表,以知道它当前有通过什么编号和某个节点保持着链接。
第一种方案
我们第一反应可以想到一种非常简单的实现方案。
我们根据有多少个可能链接的节点建立一个定长数组,每个元素代表与该节点的连接情况,0表示未链接,1表示链接。不管是建立了或者撤销了链接,我们都可以简单的写0写1,而如果是要遍历主机与多少从机建立连接也很简单。只要依次遍历数组,检查哪个位0哪个位1即可。
但是很显然,这样的程序是不具有实用性的。
首先,可能的节点可能非常多,比如上万个十万个。(zigbee是不太可能了,但以太网上的服务器却是随随便便都可能的)。但一段时间内建立连接的却可能只有几十个上百个节点。我们不说浪费空间,就说我们要遍历完一次当前建立了多少个节点,都是一件非常耗费时间的事情。
其次,在真实的环境里,我们可能会在使用中动态增加或者删除节点,这个时候,就会造成原来的程序无法使用或者冗余,降低效率。可以说,“预先设置一个固定长度“ 这种很简单很美好的想法,通常是没办法直接用在实用程序中的。
第二种方案
那我们不固定分配,我们只记录已经建立连接的节点编号呢?那我们就会很容易地发现自己面临着 我们在介绍 链表,用普通数组做对比时的那个情形。
在这个应用情境里,我们会遭遇的困境是,我们要删除某个中间的链接,将会带来极其麻烦的和耗时间的后方数据集体向前一个位置的操作......
所以,很显然,这个情形,使用 链表来解决,是最合适的方案。
本帖最后由 辛昕 于 2014-12-24 23:12 编辑
不知道大家写程序的习惯怎样。
假如是我,我会接着先考虑 维护这个链接列表,需要一些什么操作(当然,我已经完成了链表的实现)。
然后再回头去具体实现。
考虑到大家和我一样,更想实实在在看到 链表的实现,所以这里,在进一步写这个 维护链接列表 之前,我们先来考虑一下如何实现 链表吧。
这里简单概括一下。
这套实现里实际上包含了两种不同形式的 链表。
其中一种是我们可以自由使用malloc/free这两个动态分配函数 的情形,我们可以称之为标准实现方案。
然后,如前面主题楼里所提到的,为了适应大多数情况下,单片机裸机编程,不适宜大量调用malloc/free——因为没有了系统的内存管理机制,纯粹靠C标准库,很容易造成大量内存碎片,造成无法再分配足够大的连续空间。
所以,在标准实现方案之外,我们会有一个相对作出折中的 游标方案。
要注意的是,我们在认真理解了M.A.K的实现思路之后,按照他的原设计,我们将只 另外构建用于替代malloc/free行为的 游标空间数组 和 新的一对 alloc/free函数,然后使其能与标准 malloc/free函数提供相同的行为,已达到只需要更改使用这组内存分配函数,就可以使原标准实现 变成 更加适合 单片机裸机程序适用的方案。
按照前面说的,我采用的是 M.A.K C语言描述书里 的定义来实现的。
书的全名 数据结构与算法分析 ——C语言描述
我上传了一本,链接如下:
https://download.eeworld.com.cn/download/%E8%BE%9B%E6%98%95/28395
链表的”标准实现“ 在书中34页,下面贴上我自己实现的源码里的 头文件 ——我对它做了一些注释,用以理解和理清每个函数的作用。以确定要实现的内容和范围
在我的实现,我发现有某些关于链表的概念,书上没有明确理清,我们在看继续实现代码以前,最后理清一下它
——当然,在我这来说,他是我在不断实现和使用中慢慢加深了对链表的理解后得到的内容。
这里抛砖引玉,先说一说。
1.表头的约定
首先就是,M.A.K的版本中,他使用了 空表头 这个概念。
毋庸置疑,表头表示的是 ”链表开始的地方“或者说”第一个表元“,这两个解释都说的通,而且看起来似乎是一样的,实则它有非常细微的差别。
差别就在于,前者并没有强调,链表是从第一个表元开始的,这里的意思是说,前者强调的是,表头仅仅只是链表的开始位置,但这个位置本身并没有存储元素,第一个存储元素的表元在表头的next指针指向的位置;即实际上 第二个位置才是存储第一个表元的地方。
M.A.K本人也认为这除了一种理解和处理方式上的选择,是否选择空表头或者表头当第一个表元使用,完全是个人习惯,并没有绝对对错。
由于我决定严格按照他的定义实现,所以我采取了 第一种解释。
表头,指的是链表的开始位置,本身并不存储元素。
因此,在这种约定下,我们可以得到一个解释的很好的 空表的概念。
这个表只有表头,head,但是它本身没有元素,而且它的next指针也暂时指向NULL(即空地址)。
所谓空表,就是没有存储任何元素,但本身这个表却是存在的(因为表头承载了它的地址)。
在这种情况下,我认为,所谓 表头,和 表,其实可以等同认为是同一个概念
——因此你会看到,我在具体的实现中,并没有 写 Head()这个函数,因为表头,亦即表,其实是同一个概念,而且它应当是在创建表时,由主调方 获得 并且 保存的。
2.删除表元的约定
在前面的头文件中,我们会看到两个删除动作,一个是Delete,从形参可以看出,它是指删除一个表中的一个表元;另一个是 DeleteList,这个指的是 删除表;
这里一定要明确,删除表元和删除表当然是两个不同的动作,但是一个很细微的地方是,这两个动作是否会有较差的地方呢?
如果我们稍微考虑一下删除表元的极端情形,就是试图对一个空表实行删除表元时,就会发现,如果我们没有事先作出一些强制规定的话,其实是很容易造成在这种情况下,,删除表元,实际上就是删除表 的 行为的。
出于函数的单一性,即一个函数做的事情只是唯一确定的一件事情,所以,这里,既然M.A.K要特别的区分这两个函数,那表示,在他的定义中,删除表元 是不应该出现 变成删除表 这种极端情形的。
——稍后,我们在参考 M.A.K书中提供的部分函数实现时,我们会发现,这个理解是对的。
因此,为了避免这种交叉,我们对 删除表元 作出的约定就是 表头不允许删除。
至于删除表这个动作的细微之处,我们稍后会在对实现的链表做测试时,结合实际应用的情形,做一个简单说明;
暂时只想到这些,剩下的,在实现的代码中再补充注释。
本帖最后由 辛昕 于 2014-12-25 00:06 编辑
虽然还是有很罗嗦的嫌疑,但是,这次我却觉得是有必要的。
已经零点了,再写下去有点想吐。
为了免得标题党,我还是决定把 实现代码贴上来。
其实已经实现好了,唯一的问题是 残留了很多我当时的注释,这些注释应该被简练和增删。
至于一个简单的用于测试函数,明晚继续。
至于 前面提到的一个面向实际使用情形的例子。 维护链接表,将随后附上(游标方式实现)。
想了想,干脆把 游标实现方案 也 贴上来。
老规矩 分开。
先头文件,再源文件
- #ifndef _CURSOR_LIST_
- #define _CURSOR_LIST_
- // 相比于M.A.K的实现版本,因为我只想纯粹记录 链表信息,而把表元本身分离出去,所以
- // 我的 CursorSpace实际,只是一个普通 CursorPos数组,它自身的下标
- // 等同于 M.A.K概念下的 slot,而它的表元则为“next”
- typedef int CursorPos;
- #define CURSOR_NULL 0 //为了与标准malloc/free的 NULL指针对应上;
- void CursorInitSpace(CursorPos *pool,int Max);
- CursorPos CursorAlloc(CursorPos *space);
- CursorPos CursorCreate(CursorPos *space);
- void CursorInsert(CursorPos *space,CursorPos pos);
- CursorPos LastCursor(CursorPos *space,CursorPos Head);
- void CursorDelete(CursorPos *space,CursorPos head,CursorPos pos);
- #endif // _CURSOR_LIST_
- // end of file -------------------------------------------------------
- #include "CursorList.h"
- /*
- 使用一个游标数组来模拟 malloc/free行为。
- 返回的永远是一个数值,这个数值代表在 cursorspace中的数值,以及指向使用空间的
- 下标;
- 它为0则等同于NULL指针;
- 表头?表头是刚开始分配得到的位置;
- 一定要注意区分,cursorspace的0位置不是表头;
- 实现的关键,应该是首先实现 alloc/free
- */
- /*
- 为了多重性,我的 分配 和 释放函数,要多传入一个 cursorspace
- 回顾之前的版本,我是没有真正理解 链表的一些使用细节。链表在使用时,
- 是无需另外记录 链表到达的最后位置的(那是普通数组下标记录方案的后遗症)
- 通过 malloc/free本身,已经可以获得这些信息,所有需要记住的,只是链表头
- 的位置;
- 另一点,元素进入链表后,元素的排列顺序其实并不重要,除非你已经明确要把某个
- 元素插入某个位置,否则都不重要;它们总是靠next指针按顺序连接在一起;
- 最后把 0 位置 定义成 CURSOR_NULL,则在语意上,它与 一般标准的可以使用
- malloc/free的情形更加接近,更方便移植;
- 特别要注意的是,在cursorlist方案中,所有操作的指针 其实都是 数字,它们是
- CursorPos,亦即 int;
- 而且,我要实现的cursorlist方案,更加接近 linux内核中的版本,只不过我并不
- 特别在意实现为 双向链表;
- 也就是,我的链表只是记录位置本身,而不涉及所操作的元素本身;
- 对于 游标情形而言,也可以简单理解为,cursorlist只是操作 下标,然后所操作的元素
- 通过下标来索引;
- 与此同时,将不再需要 DeleteList这个操作,因为这件事情已经没有意义;
- */
- /*
- pool表示即将要使用的cursor space,记录下标的数组;
- */
- void CursorInitSpace(CursorPos *space,int Max)
- {
- int i;
- for(i = 0;i < Max - 1;i++)
- space[i] = i + 1;
- space[i] = 0;
- }
- // 这不是一个纯粹的 alloc函数,因为它同时把下一个位置置零
- CursorPos CursorAlloc(CursorPos *space)
- {
- CursorPos P;
- P = space[0];
- space[0] = space[P];
- return P;
- }
- static void CursorFree(CursorPos *space,CursorPos pos)
- {
- space[pos] = space[0];
- space[0] = pos;
- }
- CursorPos CursorCreate(CursorPos *space)
- {
- CursorPos Head;
- Head = CursorAlloc(space);
- if(Head == CURSOR_NULL)
- return;
- space[Head] = CURSOR_NULL;
- return Head;
- }
- void CursorInsert(CursorPos *space,CursorPos pos)
- {
- CursorPos temp;
- temp = CursorAlloc(space);
- if(temp == CURSOR_NULL)
- return;
- space[temp] = space[pos];
- space[pos] = temp;
- }
- CursorPos FindPrevCursor(CursorPos *space,CursorPos head,CursorPos pos)
- {
- CursorPos P;
- P = head;
- while( (space[P] != CURSOR_NULL) && space[P] != pos )
- P = space[P];
- return P;
- }
- CursorPos LastCursor(CursorPos *space,CursorPos Head)
- {
- CursorPos P;
- P = Head;
- while(space[P] != CURSOR_NULL )
- P = space[P];
- return P;
- }
- // 在一般情况下,通过指定要删除的元素,来选择删除,但这里只是一个单纯的链,
- // 而链表头不允许删除,除此以外,测试时,只能寻找一个相对确定的位置以方便删除;
- // 这里我们选择 表尾;
- void CursorDelete(CursorPos *space,CursorPos head,CursorPos pos)
- {
- CursorPos Prev,temp;
- Prev = FindPrevCursor(space,head,pos);
- if(space[Prev] == CURSOR_NULL)
- return;
- temp = space[Prev];
- space[Prev] = space[temp];
- CursorFree(space,temp);
- }
- // end of file -------------------------------------------------------
涨知识了! 感谢楼主分享!!!!!!!!!!!!
学习了,感谢楼主分享,对于算法和数据结构目前接触的不是很多,有好多东西没能很好的理解
写的非常的详细,不错不错!
好好学习一下
生活就是油盐酱醋再加一点糖,快活就是一天到晚乐呵呵的忙
===================================
做一个简单的人,踏实而务实,不沉溺幻想,不庸人自扰
学习了,平时只看理论都不怎么懂,最好想楼主这样加一个实例来讲就能很快明白!
嗯,不好意思,一直太忙,都忘了....
那啥周末啥的,我看看时间赶紧补上那个 CS模型的 维护
说得太好了
大佬...有没有关于链表排序的实际应用啊...最近写毕业论文想这个想到头秃..