数据结构笔记——向量、列表、栈

一、向量

向量结构中,各数据项的物理存放位置与逻辑次序完全对应,故可通过秩直接访问对应的元素,此即所谓“循秩访问”,各元素物理地址连续。

1.1 排序与下界

  • 比较树

1、每一内部结点各对应于一次比对操作;
2、内部节点的左右分支,分别对应于在两种比对结果(是否等重)下的执行方向;
3、叶节点(或等效地,根到叶节点的路径)对应于算法某次执行的完整过程及输出;
4、反过来,算法的每一运行过程都对应于从根到某一叶节点的路径。

按照上述规则与算法相对应的树,称作比较树。凡可如此描述的算法,都称作基于比较式算法,简称CBA式算法。具体地,在一棵高度为h的二叉树中,叶节点的数目不可能多于2h,因此,若某一个问题的输出结果不少于N种,则比较树中叶节点也不可能少于N个,树高不可能低于log2N。
同样,对于CBA式排序算法,n个元素的排序问题可能的输出共有N = n !种,元素之间不仅可以判等而且可以比较大小,故此时的比较树应属于三叉树,即每个内部节点都有三个分支(分别对应小于、等于、大于的情况)。因此,任一CBA式排序算法所对应比较树的高度应为:
h ≥ [log3(n!)] = [log3e ln(n!)] = O(nlogn)
可见,最坏情况下CBA式排序算法至少需要O(n log n )时间,其中n为待排序元素数目。需要强调的是,这一下界是针对比较树模型而言的,事实上很多不属于此类的排序算法(桶排序、基数排序)在最坏情况下的运行时间可能低于这一下界,并不矛盾。

二、列表

为保证对列表元素访问的可行性,逻辑上互为前驱和后继的元素之间,应维护某种索引关系。这种索引关系,可以抽象地理解为被索引元素的位置,故列表元素是“循位置访问”,也可以形象地理解为被索引元素的位置,故亦称之为“循链接访问”。
列表结构尽管要求各元素在逻辑上具有线性次序,但是对物理地址没有限制——“动态存储”策略。

2.1 头、尾节点

私有的头节点(header)和尾节点(trailer)始终存在,但对外并不可见,对外部可见的数据节点如果存在,则其中的第一个和最后一个节点分别称为首节点(first node)和末节点(last node)。就内部结构而言,头节点紧邻于首节点之前,尾节点紧邻于末节点之后。这类经封装之后从外部不可见的节点,称作哨兵节点(sentinel node)
在这里插入图片描述

三 栈与队列

此前介绍的向量和列表一样,均属于线性序列结构,故其中存放的数据对象之间也具有线性次序。相对于一般的序列结构,栈与队列的数据操作范围仅限于逻辑上的特定某端。本章重点不再是拘泥于对数据结构内部实现机制的展示,更多地是从外部特性出发,结合若干典型地实际问题介绍栈和队列的具体应用。
在栈的应用方面,本章结合函数调用栈的机制介绍一般函数调用的实现方式与过程,并将其推广至递归调用。然后以降低空间复杂度的目的为线索,介绍通过显式地维护栈结构解决应用问题的典型方法和基本技巧。此外,还将着重介绍如何利用栈结构,实现基于试探回溯策略的搞笑搜索算法。在队列的应用方面,主要介绍如何实现基于轮值策略的通用循环分配器,并以银行窗口服务为例实现基本的调度算法。

3.1 栈

栈可视作序列的特例,故只要将栈作为向量/列表的派生类,利用C++的继承机制实现栈结构,并根据栈的习惯,对接口重新命名。

3.2 栈与递归

递归算法所需的空间量,主要取决于最大递归深度。

3.2.1 函数调用栈

在Windows等大部分操作系统中,每个运行中的二进制程序都配有一个调用栈(call stack)或执行栈(execution stack)。借助调用栈可以跟踪属于同一程序的所有函数,记录它们之间的相互调用关系,并保证在每一调用实例执行完毕之后,可以准确地返回。

  • 函数调用
    调用栈的基本单位是帧(frame),每次函数调用时,都会相应地创建一帧,记录该函数实例在二进制程序中的返回地址,以及局部变量、传入参数等,并将该帧压入调用栈,若在该函数返回前又发生了新的调用,则同样地要将与新函数对应地一帧压入栈中,成为新地栈顶。函数一旦运行完毕,对应的帧随机弹出,运行控制权将被交还给该函数的上层调用函数,并按照该帧中记录的返回地址确定在二进制程序中继续执行的位置。
    在任一时刻,调用栈中的各帧,依次对应那些尚未返回的调用实例,即当时的活跃函数实例(active function instance)。特别地,位于栈底的那帧必然对应于入口主函数main(),若它从调用栈中弹出,则意味着整个程序的运行结束,此后控制权交还操作系统。
    仿照递归跟踪法,程序执行过程出现过的函数实例机器调用关系,也可构成一棵树,称作该程序的运行树。任一时刻的所有活跃函数实例,在调用栈中自底到顶,对应运行树中从根节点到最新活跃函数实例的一条调用路径.
    此外,调用栈中各帧还需存放其他内容。比如,因各帧规模不一,它们还需记录前一帧的起始地址,以保证其出栈之后前一帧能正确地恢复。
    在这里插入图片描述
  • 递归
    作为函数调用地特殊形式,递归也可借助上述调用栈得以实现,在图4-3中,对应于funcB()地自我调用,也会新压入一帧,可见,同一函数可能同时拥有多个实例,并在调用栈中各自占有一帧,这些帧地结构完全相同,但其中同名地参数或变量,都是独立的副本,比如在funcB()的两个实例中,入口参数m和内部变量i各有一个副本。

3.2.2 避免调用

各种高级程序设计语言几乎都允许函数直接或间接自我调用,通过递归来提高代码的简洁度和可读性。尽管如此,系统在后台隐式地维护调用栈地过程中,难以区分哪些参数和变量是对计算过程有实质作用的,更无法以通用的方式对它们进行优化,因此不得不将描述调用现场的所有参数和变量悉数入栈,再加上每一帧都必须保存的执行返回地址以及前一帧起始位置,往往导致程序的空间效率不高甚至极低;同时,隐式的入栈和出栈操作也会令实际的运行时间增加不少。
因此在追求更高效率的场合,应尽可能地避免递归,尤其是过度的递归。既然递归本身就是操作系统隐式地维护一个调用栈而实现的,我们自然可以通过显式地模拟调用栈地运转过程,实现等效的算法功能。采用这一方式,程序员可以精细地裁剪栈中各帧的内容,从而尽可能降低空间复杂度的常系数。尽管算法原递归版本的高度概括性和简洁性将大打折扣,但是空间效率方面获得足够的补偿。

3.3 栈的典型应用

3.3.1 逆序输出

在栈所擅长解决的典型问题中,有一类具有以下共同特征:首先,虽有明确的算法,但其解答却以线性序列的形式给出;其次,无论是递归还是迭代实现,该序列都是依逆序计算输出的;最后,输入和输出规模不确定,难以事先确定盛放输出数据的容器大小。因其特有的“后进先出”特性及其在容量方面的自适应性,使用栈来解决此类问题恰到好处。

  • 进制转换
    任给十进制整数n,将其转换为k进制的表示形式。
    一般地,设n = (dm……d2d1d0)(k) = dmx km + …… + d2 x k2 + d1 x k1 + d0 x k0
    若记 ni = (dm……di+1di)(k)
    则有 di = ni % k 和 ni+1 = ni / k
    可见,其输出的为长度不定的逆序线性序列。

3.3.2 递归嵌套

具有自相似性的问题多可嵌套地递归描述,但因分支位置和嵌套深度并不固定,其递归算法地复杂度不易控制。栈结构及其操作天然地具有递归嵌套性,故可用以高效地解决这类问题,以下先从混洗的角度介绍栈的递归嵌套性,然后再讲解其在具体问题中的应用。

  • 栈混洗
    考查三个栈A、B和S,其中A含有n个元素,自顶向下构成输入序列:
    { a1, a2 , ……,an}
    B和S初始为空。若只允许通过S.push(A.pop())弹出栈A的顶元素并压入栈S中,或通过B.push(S.pop())弹出S的顶元素并压入栈B中,则在经过一系列这样的操作后,当栈A和S均为空时,原A中的元素应均已转入栈B,此时,若将B中元素自底向上构成的序列记作:
    {ak1, ak2, ……,akn}
    则该序列称作原输入序列的一个栈混洗(stack permutation)。
  • 括号匹配
    对源程序的语法检查是代码编译过程中重要而基本的一个步骤,而对表达式括号匹配的检查则又是语法检查中必需的一个环节。
bool paren(const char exp[], int lo, int hi){
     // 检查括号匹配,兼顾三种括号
stack<char> S;
for(int i = 0; exp[i]; ++i)
	
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ThetaQing

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值