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

一、向量

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

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)
	switch(exp[i]){
	case '(' : case '[' : case '{' : S.push(exp[i]); break;
	case ')' : if((S.empty() || ('(' != S.pop()))) return false; break;
	case ']' : if((S.empty() || ('[' != S.pop()))) return false; break;
	case '}' : if((S.empty() || ('{' != S.pop()))) return false; break;
	default : break;  // 非括号字符一律忽略
	}
	return S.empty();  
}

3.3.3 延迟缓冲

在一些应用问题中,输入可分解为多个单元并通过迭代依次扫描处理,但过程中的各步计算往往滞后于扫描的进度,需要待到必要的信息已完整到一定程度之后,才能做出判断并实施计算。在这类场合,栈结构则可以扮演数据缓冲区的角色。

  • 表达式求值
    在编译C++程序的预处理阶段,源程序中的所有常量表达式都需首先计算并替换为对应的具体数值,而在解释型语言中,算术表达式的求值也需要随着脚本执行过程中反复进行。仅根据表达式的某一前缀,并不能完全确定其中各运算符可否执行以及执行的次序,只有在已获得足够多后续信息之后,才能确定其中哪些运算符可以执行。
  • 求值算法——逆波兰式(操作符紧邻对应的最后一个操作数后面)
    基本思路:1、两个栈,一个操作数栈,一个运算符栈;
    2、算法自左向右扫描表达式,并对其中字符逐一处理,对于已扫描但尚不能处理(运算符优先级)的操作数和运算符,分别缓存在两个栈中,一旦判断已缓存的子表达式优先级足够高,便弹出相关的操作数和运算符,执行计算,并将结果压入操作数栈。
    注意:区分操作数和运算符,一旦当前字符由非数字转为数字,意味着开始进入一个对应于操作数的子串范围。

3.4 试探回溯法

3.4.1 基本概念

  • 试探:从零开始,尝试逐步增加候选解的长度,更准确地,这一过程是在成批地考查具有特定前缀地所有候选解,这种从长度上逐步向目标解靠近的尝试,称作试探
  • 回溯:作为解的局部特征,特征前缀在试探过程中一旦被发现与目标解不合,则收缩到此前一步的长度,然后继续试探下一可能的组合。特征前缀长度缩减的这类操作,称之为回溯,其效果等同于剪枝。

3.4.2 八皇后

每行能且只能放置一个皇后,故不妨首先将各皇后分配至每一行。然后,从空棋盘开始,逐个尝试着将她们放置到无冲突的某列。每放置好一个皇后,才能继续试探下一个。若当前皇后在任何列都会造成冲突,则后续皇后的试探都必将是徒劳的,故此时应该回溯到上一皇后。

void placeQueens ( int N ) { //N皇后算法(迭代版):采用试探/回溯的策略,借助栈记录查找的结果
   Stack<Queen> solu; //存放(部分)解的栈
   Queen q ( 0, 0 ); //从原点位置出发
   do { //反复试探、回溯
      if ( N <= solu.size() || N <= q.y ) { //若已出界,则
         q = solu.pop(); q.y++; //回溯一行,并继续试探下一列
      } else { //否则,试探下一行
         while ( ( q.y < N ) && ( 0 <= solu.find ( q ) ) ) //通过与已有皇后的比对
            /*DSA*///while ((q.y < N) && (solu.find(q))) //(若基于List实现Stack,则find()返回值的语义有所不同)
            { q.y++; nCheck++; } //尝试找到可摆放下一皇后的列
         if ( N > q.y ) { //若存在可摆放的列,则
            solu.push ( q ); //摆上当前皇后,并
            if ( N <= solu.size() ) nSolu++; //若部分解已成为全局解,则通过全局变量nSolu计数
            q.x++; q.y = 0; //转入下一行,从第0列开始,试探下一皇后
         }
      }/*DSA*/if ( Step == runMode ) displayProgress ( solu, N );
   } while ( ( 0 < q.x ) || ( q.y < N ) ); //所有分支均已或穷举或剪枝之后,算法结束
}

3.4.3 迷宫寻径

空间区域限定为由n*n个方格组成的迷宫,除了四周的围墙,还有分布其间的若干障碍物:只能水平或垂直移动。在任意指定的起始格点与目标格点之间,找出一条通路(如果确实存在)。

  • 迷宫格点
    格点是迷宫的基本组成单位,除了记录其位置坐标外,格点还需要记录其所处的状态。共有四种可能的状态:原始可用的(AVAILABLE)、在当前路径上的(ROUTE)、所有方向均尝试失败后回溯过的(BACKRACKED)、不可穿越的(WALL)。属于当前路径的格点,还需记录其前驱和后继格点的方向。上下左右四个连通方向,以EAST、SOUTH、WEST和NORTH区分。特别地,因尚未搜索到而处于初始AVAILABLE状态的格点,邻接的方向都是未知的(UNKNOWN),经过回溯后处于BACKTRACKED状态的格点,与邻格之间的连通关系均已关闭,标记未NO_WAY。
typedef enum { AVAILABLE, ROUTE, BACKTRACKED, WALL } Status; //迷宫单元状态
//原始可用的、在当前路径上的、所有方向均尝试失败后回溯过的、不可使用的(墙)

typedef enum { UNKNOWN, EAST, SOUTH, WEST, NORTH, NO_WAY } ESWN; //单元的相对邻接方向
//未定、东、南、西、北、无路可通

inline ESWN nextESWN ( ESWN eswn ) { return ESWN ( eswn + 1 ); } //依次转至下一邻接方向

struct Cell { //迷宫格点
   int x, y; Status status; //x坐标、y坐标、类型
   ESWN incoming, outgoing; //进入、走出方向
};

#define LABY_MAX 24 //最大迷宫尺寸
Cell laby[LABY_MAX][LABY_MAX]; //迷宫
  • 邻格查询
    在路径试探过程中需要反复确认当前位置的相邻格点。加法,数组地址寻址吗?
    向上为北
inline Cell* neighbor ( Cell* cell ) { //查询当前位置的相邻格点
   switch ( cell->outgoing ) {
      case EAST  : return cell + LABY_MAX; //向东
      case SOUTH : return cell + 1;        //向南
      case WEST  : return cell - LABY_MAX; //向西
      case NORTH : return cell - 1;        //向北
      default    : exit ( -1 );
   }
}
  • 邻格转入
    在确认某一相邻格点可用之后,算法将朝对应的方向向前试探一步,同时路径延长一个单元。为此,需要实现相应的格点转入功能。注意,当前格点的outgoing是EAST,则对应的这个相邻格点的incoming就是WEST,是相反的关系。
inline Cell* advance ( Cell* cell ) { //从当前位置转入相邻格点
   Cell* next;
   switch ( cell->outgoing ) {
      case EAST:  next = cell + LABY_MAX; next->incoming = WEST;  break; //向东
      case SOUTH: next = cell + 1;        next->incoming = NORTH; break; //向南
      case WEST:  next = cell - LABY_MAX; next->incoming = EAST;  break; //向西
      case NORTH: next = cell - 1;        next->incoming = SOUTH; break; //向北
      default : exit ( -1 );
   }
   return next;
}
  • 算法实现
    在以上基础功能上,基于试探回溯策略实现寻径算法如下:
bool labyrinth ( Cell Laby[LABY_MAX][LABY_MAX], Cell* s, Cell* t ) {
   if ( ( AVAILABLE != s->status ) || ( AVAILABLE != t->status ) ) return false; //退化情况
   Stack<Cell*> path; //用栈记录通路(Theseus的线绳)
   s->incoming = UNKNOWN; s->status = ROUTE; path.push ( s ); //起点
   do { //从起点出发不断试探、回溯,直到抵达终点,或者穷尽所有可能
      /*DSA*/displayLaby(); /*path.traverse(printLabyCell); printLabyCell(path.top());*/ getchar();
      Cell* c = path.top(); //检查当前位置(栈顶)
      if ( c == t ) return true; //若已抵达终点,则找到了一条通路;否则,沿尚未试探的方向继续试探
      while ( NO_WAY > ( c->outgoing = nextESWN ( c->outgoing ) ) ) //逐一检查所有方向
         if ( AVAILABLE == neighbor ( c )->status ) break; //试图找到尚未试探的方向
      if ( NO_WAY <= c->outgoing ) //若所有方向都已尝试过
         { c->status = BACKTRACKED; c = path.pop(); }//则向后回溯一步
      else //否则,向前试探一步
         { path.push ( c = advance ( c ) ); c->outgoing = UNKNOWN; c->status = ROUTE; }
   } while ( !path.empty() );
   return false;
}
发布了96 篇原创文章 · 获赞 19 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/Thera_qing/article/details/104860483
今日推荐