列表

typedef int Rank; //秩
#define ListNodePosi(T) ListNode<T>* //列表节点位置template<typename T>struct ListNode//列表节点模板类(以双向链表形式实现)
{// 成员T data;ListNodePosi(T) pred;ListNodePosi(T) succ;//数值、前驱、后继// 构造函数ListNode(){}//针对header和trailer的构造ListNode(T e,ListNodePosi(T) p = NULL,ListNodePosi(T) s = NULL):data(e),pred(p),succ(s){}//默认构造器// 操作接口ListNodePosi(T) insertAsPred(T const &e);//紧靠当前节点之前插入新节点ListNodePosi(T) insertAsSucc(T const &e);//紧随当前节点之后插入新节点
};

每个节点都存有数据对象data。为保证叙述简洁,在不致歧义的前担下,本书将不再区分 节点及其对应的data对象。此外,每个节点还设有指针pred和succ,分别指向其前驱和后继。 为了创建一个列表节点对象,只需根据所提供的参数,分别设置节点内部的各个变量。其中 前驱、后继节点的位置指针若未子指定,则默认取作NULL。

列表

头、尾节点

List对象的内部组成及逻辑结构如图3.1所示,其中私有的头节点 〈header) 和尾节点(trailer) 始终存在,但对外并不可见。对外部可见的数据节点如果存在,则其中的第一个和最后一个节点分别称作首节点 〈first node) 和末节点 (last node) 。

就内部结构而言,头节点紧邻于首节点之前,尾节点紧邻于末节点之后。这类经封装之后从外部不可见的节点,称作哨兵节点〈sentinel node) 。由代码3.2中List: :valid()关于合法节点位置的判别准则可见,此处的两个哨兵节点从外部被等效地视作NULL。设置哨兵节点之后,对于从外部可见的任一节点而言,其前驱和后继在列表内部都必然存在,故可简化算法的描述与实现。比如,在代码3.2中为实现first()和last()操作,只需直接返回header->succ或trailer->pred。此外更重要地,哨兵节点的引入,也使得相关算法不必再对各种边界退化情况做专门的处理,从而避免出错的可能,我们稍后将对此有更实际的体会。尽管哨兵节点也需占用一定的空间, 但只不过是常数规模, 其成本远远低于由此带来的便利。

默认构造方法

创建List对象时,默认构造方法将调用如代码3.3所示的统一初始化过程init(),在列表内部创建一对头、尾哨兵节点,并适当地设置其前驱、后继指针构成一个双向链表。

template<typename T>void List<T>::init()//列表初始化,在创建列表对象时统一调用
{header = new ListNode<T>;//创建头哨兵节点trailer = new ListNode<T>;//创建尾哨兵节点header->succ = trailer;header->pred = NULL;trailer->pred = header;trailer->succ = NULL;_size = 0;记录规模
}

在列表的其它构造方法中,内部变量的初始化过程与此相同,因此都可统一调用init()过程。该过程仅涉及常数次基本操作,共需运行常数时间。

由秩到位置的转换

鉴于偶尔可能需要通过秩来指定列表节点,可通过重载操作符“[]”, 提供一个转换接口。

template <typename T> //重载下标操作符,以通过秩直接访问列表节点(虽方便,效率低,需慎用)
T &List<T>::operator[] ( Rank r ) const   //assert: 0 <= r < size
{ListNodePosi(T) p = first(); //从首节点出发while ( 0 < r-- ) p = p->succ; //顺数第r个节点即是return p->data; //目标节点,返回其中所存元素
}

3.3.2 具体地如代码3.4所示,为将任意指定的秩r转换为列表中对应的元素,可从首节点出发,顺着后继指针前进r步。只要秩r合法, 该算法的正确性即一目了然。其中每步选代仅需常数时间,故该算法的总体运行时间应为C(r + 1),线性正比于目标节点的秩。相对于向量同类接口的C(1)复条度,列表的这一效率十分低下一一其根源在于,列表元素的存储和访问方式已与向量截然不同。诚然,当r大于n/2时,从trailer出发沿pred指针逆行查找,可以在一定程度上减少过代次数,但就总体的平均效率而言,这一改进并无实质意义。

查找

在代码3.2中,列表ADT针对整体和区间查找,重载了操作接口find(e)和find(e,p,n)。其中,前者作为特例,可以直接调用后者。因此,只需如代码3.5所示,实现后一接口。

template <typename T> //在无序列表内节点p(可能是trailer)的n个(真)前驱中,找到等于e的最后者
ListNodePosi(T) List<T>::find ( T const &e, int n, ListNodePosi(T) p ) const
{while ( 0 < n-- ) //(0 <= n <= rank(p) < _size)对于p的最近的n个前驱,从右向左if ( e == ( p = p->pred )->data ) return p; //逐个比对,直至命中或范围越界return NULL; //p越出左边界意味着区间内不含e,查找失败
} //失败时,返回NULL

复杂度

以上算法的思路及过程,与无序向量的顺序查找算法Vector : :find() 〈代码2.16) 相仿,故时间复杂度也应是O(n),线性正比于查找区间的宽度。

插入

为将节点插至列表,可视具体要求的不同,在代码3.6所提供的多种接口中灵活选用。

template <typename T> ListNodePosi(T) List<T>::insertAsFirst ( T const &e )
{_size++; return header->insertAsSucc ( e );
} //e当作首节点插入template <typename T> ListNodePosi(T) List<T>::insertAsLast ( T const &e )
{_size++; return trailer->insertAsPred ( e );
} //e当作末节点插入template <typename T> ListNodePosi(T) List<T>::insertA ( ListNodePosi(T) p, T const &e )
{_size++; return p->insertAsSucc ( e );
} //e当作p的后继插入(After)template <typename T> ListNodePosi(T) List<T>::insertB ( ListNodePosi(T) p, T const &e )
{_size++; return p->insertAsPred ( e );
} //e当作p的前驱插入(Before)

前插入

将新元素e作为当前节点的前驱插至列表的过程,可描述和实现如代码3.7所示。

template <typename T> //将e紧靠当前节点之前插入于当前节点所属列表(设有哨兵头节点header)
ListNodePosi(T) ListNode<T>::insertAsPred ( T const &e )
{ListNodePosi(T) x = new ListNode ( e, pred, this ); //创建新节点pred->succ = x; pred = x; //设置正向链接return x; //返回新节点的位置
}

图3.3给出了整个操作的有具体过程。插入新节点之前, 列表局部的当前节点及其前驱如图(a)所示。该算法首先如图(b) 所示创建新节点new,构造函数同时将其数据项置为e,并令其后继链接succ指向当前节点,令其前驱链接pred指向当前节点的前驱节点。随后如图©所示,使new成为当前节点前驱节点的后继,使new成为当前节点的前驱〈次序不能颠倒) 。最终如图(d)所示,经过如此调整,新节点即被顺利地插至列表的这一局部。

请注意,列表规模记录的更新由代码3.6中的上层调用者负责。另外,得益于头哨兵节点的存在,即便当前节点为列表的首节点,其前驱也如图(a)所示必然存在,故不必另做特殊处理。当然,在当前节点即首节点时,前插入接口等效于List: :insertAsFirst()。

后插入

将新元素e作为当前节点的后继插至列表的过程,可描述和实现如代码3.8所示。

template <typename T> //将e紧随当前节点之后插入于当前节点所属列表(设有哨兵尾节点trailer)
ListNodePosi(T) ListNode<T>::insertAsSucc ( T const &e )
{ListNodePosi(T) x = new ListNode ( e, this, succ ); //创建新节点succ->pred = x; succ = x; //设置逆向链接return x; //返回新节点的位置
}

后插入的操作过程以及最终效果与前插入完全对称,不再獒述。

基于复制的构造

与向量一样, 列表的内部结构也是动态创建的,故利用默认的构造方法并不能真正地完成新列表的复制创建。为此,需要专门编写相应的构造方法,通过复制某一已有列表来构造新列表。

copyNodes()

尽管这里提供了多种形式,以允许对原列表的整体或局部复制,但其实质过程均大同小异,都可概括和转化为如代码3.9所示的底层内部方法copyNodes( )。在输入参数台法的前提下,copyNodes()首先调用init()方法,创建头、尾哨兵节点并做相应的初始化处理,然后自p所指节点起,从原列表中取出n个相邻的节点,并逐一作为末节点插至新列表中。

template <typename T> //列表内部方法:复制列表中自位置p起的n项
void List<T>::copyNodes ( ListNodePosi(T) p, int n )   //p合法,且至少有n-1个真后继节点
{init(); //创建头、尾哨兵节点并做初始化while ( n-- ) { insertAsLast ( p->data ); p = p->succ; } //将起自p的n项依次作为末节点插入
}

根据此前的分析, init()操作以及各步适代中的插入操作均只需常数时间, 故copyNodes()过程总体的运行时间应为C(n + 1),线性正比于待复制列表区间的长度n。

如代码3.16所示,基于上述copyNodes()方法可以实现多种接口,通过复制已有列表的区间或整体,构造出新列表。其中,为了复制列表L中自秩r起的n个相邻节点,List(L,r,m)需借助重载后的下标操作符,找到待复制区间起始节点的位置,然后再以此节点作为参数调用copyNodes()。根据3.3.3节的分析结论,需要花费O(r + 1)的时间才能将r转换为起始节点的位置,故该复制接口的总体复杂度应为O(r + n + 1),线性正比于被复制节点的最高秩。由此也可再次看出,在诸如列表之类采用动态存储策略的结构中,循秩访问远非有效的方式。

template <typename T> //复制列表中自位置p起的n项(assert: p为合法位置,且至少有n-1个后继节点)
List<T>::List ( ListNodePosi(T) p, int n ) { copyNodes ( p, n ); }template <typename T> //整体复制列表L
List<T>::List ( List<T> const& L ) { copyNodes ( L.first(), L._size ); }template <typename T> //复制L中自第r项起的n项(assert: r+n <= L._size)
List<T>::List ( List<T> const& L, int r, int n ) { copyNodes ( L[r], n ); }

删除

在列表中删除指定节点p的算法,可以描述并实现如代码3.11所示。

template <typename T> T List<T>::remove ( ListNodePosi(T) p )   //删除合法节点p,返回其数值
{T e = p->data; //备份待删除节点的数值(假定T类型可直接赋值)p->pred->succ = p->succ; p->succ->pred = p->pred; //后继、前驱delete p; _size--; //释放节点,更新规模return e; //返回备份的数值
}

图3.4给出了整个操作的有具体过程。删除节点之前,列表在位置p附近的局部如图(a)所示。为了删除位置p处的节点,首先如图(b)所示,令其前驱节点与后继节点相互链接。然后如图©所示, 释放掉已经孤立出来的节点p,,同时相应地更新列表规模计数器_size。 最终如图(d)所示,经过如此调整之后,原节点p即被顺利地从列表中摘除。

析构

释放资源及清除节点

与所有对象一样,列表对象析构时也需如代码3.12所示,将其所占用的资源归还操作系统。

template <typename T> List<T>::~List() //列表析构器
{clear(); delete header; delete trailer;
} //清空列表,释放头、尾哨兵节点

可见,列表的析构需首先调用clear( )接口删除并释放所有对外部有效的节点,然后释放内部的头、尾哨兵节点。而clear( 过程则可描述和实现如代码3.13所示。

template <typename T> int List<T>::clear()   //清空列表
{int oldSize = _size;while ( 0 < _size ) remove ( header->succ ); //反复删除首节点,直至列表变空return oldSize;
}

复杂度

这里的时间消耗主要来自clear()操作, 该操作通过remove( )接口反复删除列表的首节点。因此,clear( )方法以及整个析构方法的运行时间应为C(n),线性正比于列表原先的规模。

唯一化

实现

旨在吻除无序列表中重复元素的捷口deduplicate( ),可实现如代码3.14所示。

template <typename T> int List<T>::deduplicate()   //剔除无序列表中的重复节点
{if ( _size < 2 ) return 0; //平凡列表自然无重复int oldSize = _size; //记录原规模ListNodePosi(T) p = header; Rank r = 0; //p从首节点开始while ( trailer != ( p = p->succ ) )   //依次直到末节点{ListNodePosi(T) q = find ( p->data, r, p ); //在p的r个(真)前驱中查找雷同者q ? remove ( q ) : r++; //若的确存在,则删除之;否则秩加一} //assert: 循环过程中的任意时刻,p的所有前驱互不相同return oldSize - _size; //列表规模变化量,即被删除元素总数
}

与算法Vector: :deduplicate()《〈42页代码2.14) 类似,这里也是自前向后依次处理各节点p,一旦通过find()接口在p的前驱中查到雷同者,则随即调用remove( )接口将其删除。

正确性

向量与列表中元素的逻辑次序一致,故二者的deduplicate()算法亦具有类似的不变性和单调性 ,故正确性均可保证。

复杂度

与无序向量的去重算法一样,该算法总共需做2(n)步多代。由3.3.4节的分析结论, 每一步迭代中find()操作所需的时间线性正比于查找区间宽度,即当前节点的秩: 由3.3.7节的分析结论,列表节点每次remove( )操作仅需常数时间。因此,总体执行时间应为1+2+3+...+n=n(n+1)/2=O(n2)1+2+3+...+n = n(n+1)/2 = O(n^2)1+2+3+...+n=n(n+1)/2=O(n2)相对于无序向量,尽管此处节点删除操作所需的时间减少,但总体渐进复条度并无改进。

遍历

列表也提供支持节点批量式访问的遍历接口,其实现如代码3.15所示。

template <typename T> void List<T>::traverse ( void ( *visit ) ( T & ) ) //借助函数指针机制遍历
{  for ( ListNodePosi(T) p = header->succ; p != trailer; p = p->succ ) visit ( p->data );
}template <typename T> template <typename VST> //元素类型、操作器
void List<T>::traverse ( VST &visit ) //借助函数对象机制遍历
{  for ( ListNodePosi(T) p = header->succ; p != trailer; p = p->succ ) visit ( p->data );
}

唯一化

与有序向量同理,有序列表中的雷同节点也必然《在逻辑上) 彼此紧邻。利用这一特性,可实现重复节点删除算法如代码3.16所示。位置指针p和q分别指癌每一对相邻的节点,若二者雷同则删除q,否则转向下一对相令节点。如此反复迁代,直至检查过所有节点。

template <typename T> int List<T>::uniquify()   //成批剔除重复元素,效率更高
{if ( _size < 2 ) return 0; //平凡列表自然无重复int oldSize = _size; //记录原规模ListNodePosi(T) p = first(); ListNodePosi(T) q; //p为各区段起点,q为其后继while ( trailer != ( q = p->succ ) ) //反复考查紧邻的节点对(p, q)if ( p->data != q->data ) p = q; //若互异,则转向下一区段else remove ( q ); //否则(雷同),删除后者return oldSize - _size; //列表规模变化量,即被删除元素总数
}

插入排序

插入排序 (insertionsort) 算法适用于包括向量与列表在内的任何序列结构。 算法的思路可简要描述为: 始终将整个序列视作并切分为两部分: 有序的前绷, 无序的后绥, 通过夫代,反复地将后绷的首元素转移至前级中。由此亦可看出插入排序算法的不变性,

在任何时刻,相对于当前节点e = S[r],前级s[0,r)总是业已有序.

算法开始时该前缀为空,不变性自然满足。现假设如图3.5(a)所示前缀S[0,r)已经有序.接下来, 借助有序序列的查找算法, 可在该前缀中定位到不大于e的最大元素。于是只需将e从无序后组中取出, 并紧邻于查找返回的位置之后插入, 即可如图(b)所示,使得有序前缀的范围扩大至S[0,r]。如此, 该前缀的范围可不断拓展。当其最终费盖整个序列时,亦即整体有序。

这里,前后共经7步先代。输入序列中的7个元素以秩为序,先后作为首元素被取出,并插至有序前组子序列中的适当位置。新近插入的元素均以方框注明,为确定其插入位置而在查找操作过程中接受过大小比较的元素以下划线示意。

实现

template <typename T> //列表的插入排序算法:对起始于位置p的n个元素排序
void List<T>::insertionSort ( ListNodePosi(T) p, int n )   //valid(p) && rank(p) + n <= size
{/*DSA*/printf ( "InsertionSort ...\n" );for ( int r = 0; r < n; r++ )   //逐一为各节点{insertA ( search ( p->data, r, p ), p->data ); //查找适当的位置并插入p = p->succ; remove ( p->pred ); //转向下一节点}
}

复杂度

插入排序算法共由n步迁代组成,故其运行时间应取决于,各步迁代中所执行的查找、删除及插入操作的效率。根据此前3.3.5节和3.3.7节的结论,插入操作insertA()和删除操作Premove()均只需O(1)时间; 而由3.4.2节的结论, 查找操作search( )所需时间可在O(1)至O(n)之间浮动《从如表3.3所示的实例,也可看出这一点) 。不难验证,当得入序列已经有序时,该算法中的每次search( )操作均仅需O(1)时间,总体运行时间为O(n)。但反过来,若输出序列完全遂序, 则各次search( )操作所需时间将线性递增,累计共需O(n2)时间。在等概率条件下,平均仍需要O(n )时间〔〈习题[3-16]) 。

选择排序

选择排序(selectionsort) 也适用于向量与列表之类的序列结构。

与插入排序类似,该算法也将序列划分为无序前级和有序后缀两部分; 此外,还要求前缀不大于后缀。如此,每次只需从前缀中选出最大者,并作为最小元素转移至后缘中,即可使有序部分的范围不断扩张。

在算法的初始时刻,后缀为空,不变性自然满足。如图3.6(a )所示,假设不变性己满足。于是,可调用无序序列的查找算法,从前绥中找出最大者M。接下来,只需将M从前绥中取出并作为首元素插入后缀,即可如图(b)所示,使得后级的范围扩大,并继续保持有序。如此,该后缘的范围可不断拓展。当其最终覆盖整个序列时,亦即整体有序。

template <typename T> //列表的选择排序算法:对起始于位置p的n个元素排序
void List<T>::selectionSort ( ListNodePosi(T) p, int n )   //valid(p) && rank(p) + n <= size
{/*DSA*/printf ( "SelectionSort ...\n" );ListNodePosi(T) head = p->pred; ListNodePosi(T) tail = p;for ( int i = 0; i < n; i++ ) tail = tail->succ; //待排序区间为(head, tail)while ( 1 < n )   //在至少还剩两个节点之前,在待排序区间内{ListNodePosi(T) max = selectMax ( head->succ, n ); //找出最大者(歧义时后者优先)insertB ( tail, remove ( max ) ); //将其移至无序区间末尾(作为有序区间新的首元素)tail = tail->pred; n--;}
}
template <typename T> //从起始于位置p的n个元素中选出最大者
ListNodePosi(T) List<T>::selectMax ( ListNodePosi(T) p, int n )
{ListNodePosi(T) max = p; //最大者暂定为首节点pfor ( ListNodePosi(T) cur = p; 1 < n; n-- ) //从首节点p出发,将后续节点逐一与max比较if ( !lt ( ( cur = cur->succ )->data, max->data ) ) //若当前元素不小于max,则max = cur; //更新最大元素位置记录return max; //返回最大节点位置
}

归并排序

节介绍过基于二路归并的向量排序算法,其构思也同样适用于列表结构。实际上,有序列表的二路归并不仅可以实现,而且熊够达到与有序向量二路归并同样高的效率。

二路归并算法的实现

template <typename T> //有序列表的归并:当前列表中自p起的n个元素,与列表L中自q起的m个元素归并
void List<T>::merge ( ListNodePosi(T) & p, int n, List<T> &L, ListNodePosi(T) q, int m )
{// assert:  this.valid(p) && rank(p) + n <= size && this.sorted(p, n)
//          L.valid(q) && rank(q) + m <= L._size && L.sorted(q, m)
// 注意:在归并排序之类的场合,有可能 this == L && rank(p) + n = rank(q)ListNodePosi(T) pp = p->pred; //借助前驱(可能是header),以便返回前 ...while ( 0 < m ) //在q尚未移出区间之前if ( ( 0 < n ) && ( p->data <= q->data ) ) //若p仍在区间内且v(p) <= v(q),则{ if ( q == ( p = p->succ ) ) break; n--; } //p归入合并的列表,并替换为其直接后继else //若p已超出右界或v(q) < v(p),则{ insertB ( p, L.remove ( ( q = q->succ )->pred ) ); m--; } //将q转移至p之前p = pp->succ; //确定归并后区间的(新)起点
}

仿照向量的归并排序算法mergesort() (62页代码2.28) ,采用分治策略并基于以上有序列表的二路归并算法,可如代码3.23所示,递归地描述和实现列表的归并排序算法。

template <typename T> //列表的归并排序算法:对起始于位置p的n个元素排序
void List<T>::mergeSort ( ListNodePosi(T) & p, int n )   //valid(p) && rank(p) + n <= size
{/*DSA*/printf ( "\tMERGEsort [%3d]\n", n );if ( n < 2 ) return; //若待排序范围已足够小,则直接返回;否则...int m = n >> 1; //以中点为界ListNodePosi(T) q = p; for ( int i = 0; i < m; i++ ) q = q->succ; //均分列表mergeSort ( p, m ); mergeSort ( q, n - m ); //对前、后子列表分别排序merge ( p, m, *this, q, n - m ); //归并
} //注意:排序后,p依然指向归并后区间的(新)起点

stack模板类

既然写可视作序列的特例,故只要将栈作为向量的派生类,即可利用C++的继承机制,基于2.2.3节定义的向量模板类实现栈结构。当然,这里需要按照栈的习惯,对各楼口重新命名。按照表4.1所列的ADT接口,可描述并实现stack模板类如代码4.1所示。

template <typename T> class Stack: public Vector<T>   //将向量的首/末端作为栈底/顶
{public: //size()、empty()以及其它开放接口,均可直接沿用void push ( T const &e ) { insert ( size(), e ); } //入栈:等效于将新元素作为向量的末元素插入T pop() { return remove ( size() - 1 ); } //出栈:等效于删除向量的末元素T &top() { return ( *this ) [size() - 1]; } //取顶:直接返回向量的末元素
};

栈与递归

递归算法所需的空间量,主要决定于最大递归深度。在达到这一深度的了时刻,同时活跃的递归实例达到最多。那么,操作系统具体是如何实现函数《递归) 调用的? 如何记录调用与被调用函数〈递归) 实例之间的关系? 如何实现函数〈递归) 调用的返回?又是如何维护同时活跃的所有函数《〈递归) 实例的? 所有这些问题的答案,都可归结于栈。

在Windows等大部分操作系统中,每个运行中的二进制程序都配有一个调用栈〈cal1stack) 或执行栈 Cexecution stack) 。借助调用栈可以跟踪属于同一程序的所有函数,记录它们之闻的相互调用关系,并保证在每一调用实例执行完毕之后,可以准确地返回。国 ”函数调用如图4.3所示,调用栈的基本单位是帧 “frame) 。每次函数调用时,都会相应地创建一帧,记录该函数实例在二进制程序中的返回地址《return address) ,以及局部变量、传入参数等,并将该帧压入调用栈。 益在该函数返回之前又发生新的调用,则同样地要将与新函数对应的一帧压入栈中,成为新的栈顶。函数一旦运行完毕,对应的帧随即弹出,运行控制权将被交还给该函数的上层调用函数,并按照该帧中记录的返回地址确定在二进制程序中继续执行的位置。在任一时刻,调用栈中的各帧,依次对应于那些尚未返回的调用实例,亦即 当时的活跃函数实例(active function instance) 。特别地,位于栈底的那帧必然对应于入口主函数main( ),若它从调用栈中弹出,则意味着整个程序的运行结束,此后控制权将交还给操作系统。仿照递归跟踪法,程序执行过程出现过的函数实例及其调用关系,也可构成一棵树,称作该程序的运行树。任一时刻的所有活跃函数实例,在调用栈中自底到项,对应于运行树中从根节点

栈的典型应用

逆序输出

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

进制转换

考查如下问题,任给十进制整数n,将其转换为X进制的表示形式。

比如 lambda=8lambda=8lambda=8 时有12345(10)=30071(8)12345_{(10)}=30071_{(8)}12345(10)​=30071(8)​

一般的 设 n=(dm…d2d1dθ)(λ)=dm×λm+…+d2×λ2+d1×λ1+d0×λ0n=\left(d_{m} \ldots d_{2} d_{1} d_{\theta}\right)_{(\lambda)}=d_{m} \times \lambda^{m}+\ldots+d_{2} \times \lambda^{2}+d_{1} \times \lambda^{1}+d_{0} \times \lambda^{0}n=(dm​…d2​d1​dθ​)(λ)​=dm​×λm+…+d2​×λ2+d1​×λ1+d0​×λ0

若记 ni=(dm…di+1d1)(λ)n_{i}=\left(\begin{array}{llll} d_{m} & \ldots & d_{i+1} & d_{1} \end{array}\right)(\lambda)ni​=(dm​​…​di+1​​d1​​)(λ)

则有 di=ni%λ和 ni+1=ni/λ\text { 则有 } \quad \mathrm{d}_{\mathrm{i}}=\mathrm{n}_{\mathrm{i}} \% \lambda \quad \text { 和 } \quad \mathrm{n}_{\mathrm{i}+1}=\mathrm{n}_{\mathrm{i}} / \lambda 则有 di​=ni​%λ 和 ni+1​=ni​/λ

递归实现

根据如图4.4所示的计算流程,可得到如代码4.2所示递归式算法。

void convert ( Stack<char> &S, __int64 n, int base )   //十进制数n到base进制的转换(递归版)
{static char digit[] //0 < n, 1 < base <= 16,新进制下的数位符号,可视base取值范围适当扩充= { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F' };if ( 0 < n )   //在尚有余数之前,不断{convert ( S, n / base, base ); //通过递归得到所有更高位S.push ( digit[n % base] ); //输出低位}
} //新进制下由高到低的各数位,自顶而下保存于栈S中

尽管新进制下的各数位须按由低到高次序逐位复出,但只要引入一个栈并将算得的数位依次 入栈,则在计算结束后只需通过反复的出栈操作即可由高到低地将其顺序输出。

迭代实现

这里的静态数位符号衣在全局只需保留一份, 但与一般的递归函数一样,该函数在递归调用栈中的每一帧都仍需记录参数S、n和base。将它们改为全局变量固然可以节省这部分空间,但依然不能彻底地避免因调用栈操作而导致的空间和时间消耗。为此,不妨考虑改写为如代码4.3所示的选代版本,既能充分发挥栈处理此类问题的特长,又可将空间消耗降至O(1)。

void convert ( Stack<char> &S, __int64 n, int base )   //十进制数n到base进制的转换(迭代版)
{static char digit[] //0 < n, 1 < base <= 16,新进制下的数位符号,可视base取值范围适当扩充= { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F' };while ( n > 0 )   //由低到高,逐一计算出新进制下的各数位{int remainder = ( int ) ( n % base ); S.push ( digit[remainder] ); //余数(当前位)入栈printf ( "%20I64d =", n );n /= base; //n更新为其对base的除商printf ( "%20I64d * %d + %d\n", n, base, remainder );print ( S );   getchar();}
} //新进制下由高到低的各数位,自顶而下保存于栈S

递归嵌套

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

栈混洗

考查三个栈A、B和S。其中,B和S初始为空,A含有n个元素,自项而下构成输入序列:A=<aa...,an]A= <aa ...,an]A=<aa...,an]这里,分别用尖括号、方括号示意栈顶、栈底,这也是本小节将统一采用的约定。

​ 以下,若只允许通过S.push(A.pop())S.push( A.pop() )S.push(A.pop())弹出栈A的项元素并随即压入栈B中,或通过B.push( S.pop() )弹出S的顶元素并随即压入栈B中,则在经过这两类操作各n次之后,栈A和S有可能均为空,原A中的元素均已转入栈B。此时,若将B中元素自底而上构成的序列记作:B=[ak1,ak2,...,akn>B = [a_{k1}, a_{k2},...,a_{kn}>B=[ak1​,ak2​,...,akn​>

则该序列称作原输入序列的一个栈混洗〈stack permutation) 。

如图4.5所示,设最初栈A = 《 1,2,3,4 ],栈S和B均为空,经过“随机的”8次操作,A中元素全部转入栈B中。此时,栈B中元素所对应的序列[ 3,2,4,1 >,即是原序列的一个栈混洗。除了“实施出栈操作时栈不得为空”,以上过程并无更多限制,故栈混洗并不唯一。就此例而言,[ 1,2,3,4 >、[ 4,3,2,1 >以及[ 3,2,1,4 >等也是栈混洗。

从图4.5也可看出,一般地对于长度为n的输入序列,每一栈混洗都对应于由栈s的n次push和mn次pop构成的某一合法操作序列,比如[ 3,2,4,1 >即对应于操作序列:{ push , push, push, pop,pop, push , pop,pop }反之,由n次push和mn次pop构成的任何操作序列,只要满足“任一前缀中的push不少于pop”这一阪制,则该序列也必然对应于某个栈混洗〈习题[4-4]) 。

括号匹配

对源程序的语法检查是代码编译过程中重要而基本的一个步又, 而对表达式括号史权的检查则又是语法检查中必需的一个环节。其任务是,对任一程序块,判断其中的括号是否在嵌套的意义下完全匹配《简称匹配) 。比如在以下两个表达式中,前者匹配,而后者不匹配。
a/(b[i−1][j+1]+C[i+1][j−1])∗2a/(b[i−1][j+1])+C[i+1][j−1])∗2\begin{array}{l} \mathrm{a} /(\mathrm{b}[\mathrm{i}-1][\mathrm{j}+1]+\mathrm{C}[\mathrm{i}+1][j-1]) * 2 \\ \mathrm{a} /\left(\mathrm{b}[\mathrm{i}-1][\mathrm{j}+1])+\mathrm{C}[\mathrm{i}+1]\left[\begin{array}{l} j-1 \end{array}\right]\right) * 2 \end{array} a/(b[i−1][j+1]+C[i+1][j−1])∗2a/(b[i−1][j+1])+C[i+1][j−1​])∗2​

递归实现

不妨先只考虑圆括号。用’+’ 表示表达式的串接。不难理解,一般地,若表达式S可分解为如下形式,

S=S0+"("+S1+")"+S2+S3S = S_0 +"(" + S_1 + ")"+ S_2 + S_3S=S0​+"("+S1​+")"+S2​+S3​

其中S0S_0S0​和S3S_3S3​不含括号,且S1S_1S1​中左、右括号数目相等,则S匹配当且仅当S1S_1S1​和S2S_2S2​均匹配。按照这一理解,可采用分治策略设计算法如下: 将表达式划分为子表达式S0S_0S0​、S1S_1S1​和S2S_2S2​,分别递归地判断S1S_1S1​和S2S_2S2​;是否匹配。这一构思可有具体实现如代码4.4所示。

void trim ( const char exp[], int &lo, int &hi )   //删除exp[lo, hi]不含括号的最长前缀、后缀
{while ( ( lo <= hi ) && ( exp[lo] != '(' ) && ( exp[lo] != ')' ) ) lo++; //查找第一个和while ( ( lo <= hi ) && ( exp[hi] != '(' ) && ( exp[hi] != ')' ) ) hi--; //最后一个括号
}int divide ( const char exp[], int lo, int hi )   //切分exp[lo, hi],使exp匹配仅当子表达式匹配
{int mi = lo; int crc = 1; //crc为[lo, mi]范围内左、右括号数目之差while ( ( 0 < crc ) && ( ++mi < hi ) ) //逐个检查各字符,直到左、右括号数目相等,或者越界{  if ( exp[mi] == ')' )  crc--; if ( exp[mi] == '(' )  crc++;  } //左、右括号分别计数return mi; //若mi <= hi,则为合法切分点;否则,意味着局部不可能匹配
}bool paren ( const char exp[], int lo, int hi )   //检查表达式exp[lo, hi]是否括号匹配(递归版)
{/*DSA*/displaySubstring ( exp, lo, hi );trim ( exp, lo, hi ); if ( lo > hi ) return true; //清除不含括号的前缀、后缀if ( exp[lo] != '(' ) return false; //首字符非左括号,则必不匹配if ( exp[hi] != ')' ) return false; //末字符非右括号,则必不匹配int mi = divide ( exp, lo, hi ); //确定适当的切分点if ( mi > hi ) return false; //切分点不合法,意味着局部以至整体不匹配return paren ( exp, lo + 1, mi - 1 ) && paren ( exp, mi + 1, hi ); //分别检查左、右子表达式
}

迭代实现
实际上,只要将push、pop操作分别与堪、右括号相对应,则长度为n的栈混洗,必然与由n对括号组成的合法表达式彼此对应〈习题[4-4]) 。比如,二混洗[ 3,2,4,1 >对应于表达式"((())())"。接照这一理解,借助栈结构,只需扫描一趟表达式,即可在线性时间内,判定其中的括号是否匹配。这一新的算法,可简明地实现如代码4.5所示。

bool paren ( const char exp[], int lo, int hi )   //表达式括号匹配检查,可兼顾三种括号
{Stack<char> S; //使用栈记录已发现但尚未匹配的左括号for ( int i = lo; i <= hi; i++ ) /* 逐一检查当前字符 */ /*DSA*/{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; //非括号字符一律忽略/*DSA*/} displayProgress ( exp, i, S );}return S.empty(); //整个表达式扫描过后,栈中若仍残留(左)括号,则不匹配;否则(栈空)匹配
}

延迟缓冲

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

表达式求值

表达式求值
在编译C++程序的预处理阶段,源程序中的所有常量表达式都需首先计算并蔡换为对应的具体数值。而在解释型语言中,算术表达式的求值也需随着脚本执行过程中反复进行。比如,在UNIX Shel1、D0Ss Shel1和postscript交互窗口中分别和输入:再 echo $((0+(1+23) / 4 * 5 * 67 - 8 + 9)) > set /a ((9+(1+23)7/4+5*67 -8+9)) GS> 0 1 23 add 4 div 5 mul 67 mul add 8 sub 9 add = 都将返回“2011”。可见, 不能简单地按照“先左后在”的次序执行表达式中的运算符。关于运算符执行次序的规则《〈即运算优先级) ,一部分决定于事先约定的惯例〈比如乘除优先于加减),另一部分则决定于括号。也就是说,仅根据表达式的某一前绥,并不能完全确定其中各运算符可否执行以及执行的次序,只有在已获得足够多后续信息之后,才能确定其中哪些运算符可以执行。

优先级表

我们首先如代码4. 6所示,将不同运算符之间的运算优先级关系,描述为一张二维表格。

#define N_OPTR 9 //运算符总数
typedef enum { ADD, SUB, MUL, DIV, POW, FAC, L_P, R_P, EOE } Operator; //运算符集合
//加、减、乘、除、乘方、阶乘、左括号、右括号、起始符与终止符
const char pri[N_OPTR][N_OPTR] =   //运算符优先等级 [栈顶] [当前]
{/*              |-------------------- 当 前 运 算 符 --------------------| *//*              +      -      *      /      ^      !      (      )      \0 *//* --  + */    '>',   '>',   '<',   '<',   '<',   '<',   '<',   '>',   '>',/* |   - */    '>',   '>',   '<',   '<',   '<',   '<',   '<',   '>',   '>',/* 栈  * */    '>',   '>',   '>',   '>',   '<',   '<',   '<',   '>',   '>',/* 顶  / */    '>',   '>',   '>',   '>',   '<',   '<',   '<',   '>',   '>',/* 运  ^ */    '>',   '>',   '>',   '>',   '>',   '<',   '<',   '>',   '>',/* 算  ! */    '>',   '>',   '>',   '>',   '>',   '>',   ' ',   '>',   '>',/* 符  ( */    '<',   '<',   '<',   '<',   '<',   '<',   '<',   '=',   ' ',/* |   ) */    ' ',   ' ',   ' ',   ' ',   ' ',   ' ',   ' ',   ' ',   ' ',/* -- \0 */    '<',   '<',   '<',   '<',   '<',   '<',   '<',   ' ',   '='
};
Operator optr2rank ( char op )   //由运算符转译出编号
{switch ( op ){case '+' : return ADD; //加case '-' : return SUB; //减case '*' : return MUL; //乘case '/' : return DIV; //除case '^' : return POW; //乘方case '!' : return FAC; //阶乘case '(' : return L_P; //左括号case ')' : return R_P; //右括号case '\0': return EOE; //起始符与终止符default  : exit ( -1 ); //未知运算符}
}char orderBetween ( char op1, char op2 ) //比较两个运算符之间的优先级
{ return pri[optr2rank ( op1 ) ][optr2rank ( op2 ) ]; }float evaluate ( char *S, char *&RPN )   //对(已剔除白空格的)表达式S求值,并转换为逆波兰式RPN
{Stack<float> opnd; Stack<char> optr; //运算数栈、运算符栈 /*DSA*/任何时刻,其中每对相邻元素之间均大小一致/*DSA*/ char *expr = S;optr.push ( '\0' ); //尾哨兵'\0'也作为头哨兵首先入栈while ( !optr.empty() )   //在运算符栈非空之前,逐个处理表达式中各字符{if ( isdigit ( *S ) )   //若当前字符为操作数,则{readNumber ( S, opnd ); append ( RPN, opnd.top() ); //读入操作数,并将其接至RPN末尾}else   //若当前字符为运算符,则switch ( orderBetween ( optr.top(), *S ) )   //视其与栈顶运算符之间优先级高低分别处理{case '<': //栈顶运算符优先级更低时optr.push ( *S ); S++; //计算推迟,当前运算符进栈break;case '=': //优先级相等(当前运算符为右括号或者尾部哨兵'\0')时optr.pop(); S++; //脱括号并接收下一个字符break;case '>':   //栈顶运算符优先级更高时,可实施相应的计算,并将结果重新入栈{char op = optr.pop(); append ( RPN, op ); //栈顶运算符出栈并续接至RPN末尾if ( '!' == op )   //若属于一元运算符{float pOpnd = opnd.pop(); //只需取出一个操作数,并opnd.push ( calcu ( op, pOpnd ) ); //实施一元计算,结果入栈}else     //对于其它(二元)运算符{float pOpnd2 = opnd.pop(), pOpnd1 = opnd.pop(); //取出后、前操作数 /*DSA*/提问:可否省去两个临时变量?opnd.push ( calcu ( pOpnd1, op, pOpnd2 ) ); //实施二元计算,结果入栈}break;}default : exit ( -1 ); //逢语法错误,不做处理直接退出}//switch/*DSA*/displayProgress ( expr, S, opnd, optr, RPN );}//whilereturn opnd.pop(); //弹出并返回最后的计算结果
}

该算法自左向右扫描表达式,并对其中字符逐一做相应的处理。那些已经扫描过但〈因信息不足) 尚不能处理的操作数与运算符,将分别缓冲至栈opnd和栈optr。一旦判定已缓存的子表达式优先级足够高,便弹出相关的操作数和运算符,随即执行运算,并将结果压入栈opnd。请留意这里区分操作数和运算符的技巧。一旦当前字符由非数字转为数字, 则意味着开始进入一个对应于操作数的子串范围。由于这里允许操作数含有多个数位,甚至可能是小数,故可调用readNumber( )函数《〈习题[4-6]) ,根据当前字符及其后续的阁二字符,利用另一个栈解析出当前的操作数。解析完毕,当前字符将再次聚焦于一个非数字字符。

不同优先级的处置

按照代码4.7,若当前字符为运算符,则在调用orderBetween( ) 函数《习题[4-7]) ,将其与栈optr的栈项操作符做一比较之后,即可视二者的优先级高低,分三种情况相应地处置。

以表达式" 1 + 2 * 3 … “为例 在扫描到运算符’* ‘时,optr栈项运算符为此前的’+",由于pri[ +’][’* ] = ,当前运算符’* "优先级更高,故栈顶运算符’+ “的执行必须推返。请注意,由代码4.6定义的优先级表,无论栈顶元素如何,当前操作符为”( "的所有情况均统一归入这一处理方式,另外,无论当前操作符如何,栈项操作符为’( '的所有情况也统一按此处理。也就是说,所有左括号及其后紧随的一个操作符都会相继地被直接压入optr栈中,而此前的运息符则一律押后执行一这与左括号应有的功能完全吻合。

对右括号的上述处理方式,将在optr栈项出现操作符’( "时终止一一由代码4.6可知,pri[’ (][)] =“=’。此时,将弹出栈顶的’(’ ,然后继续处理’ ) ‘之后的字符。不难看出,这对左、右括号在表达式中必然相互匹配,其作用在于约束介乎二者之问的那段子表达式的优先级关系,故在其“历史使命”完成之后,算法做如上处置理所应当。除左、右括号外,还有一种优先级相等的全法情况,即pri[’\e’][’\8@’] =“=’。由于在算法启动之初已经首先将字符’ \8’ 压入optr栈,故在整个表达式已被正确解析并抵达表达式结柬标识符’@'时,即出现这一情况。对于合法的表达式,这种情况只在算法终止前出现一次。既然同是需要弹出栈顶,算法不仿将这种情况按照优先级相等的方式处置。国 ”语法检查及和鲁棒性为简洁起见,以上算法假设输入志达式的语法完全正确,和否则,有可能会导致忒诞的结果。读者可在此基础上,党试扩充语法检查以及对各种非法情况的处理功能〈习题[4-12]) 。

逆波兰表达式

RPN

逆波兰表达式 〈reverse Polish notation,RPN) 是数学表达式的一种,其语法规则可概括为,操作符紧邻于对应的《最后一个) 操作数之后。比如“1 2+”即通常习惯的“1+ 2”。按此规则,可逆归地得到更复杂的表达式,比如RPN表达式1 2 + 3 4 ^ 即对应于常规的表达式(1+2) * 3^4

RPN表达式亦称作后缀表达式 〔postfix) ,原表达式则称作中缀表达式 (infix) 。尽管RPN表达式不够直观易读,但其对运算符优先级的表述能力,却毫不逊色于常规的中缀表达式,而其在计算效率方面的优势,更是常规表达式无法比拟的。RPN表达式中运算符的执行次序,可更为简捷地确定,既不必在事先做任何约定,更无需借助括号强制改变优先级。有具体而言,各运算符被执行的次序,与其在RPN表达式中出现的次序完全易人台。以上面的" 1 2 + 3 4 ^ * ”为例,三次运算的次序{ +,^,*# },与三个运算符的出现次序完全一致。

求值算法

根据以上分析,采用算法4.1即可高效地实现对RPN表达式的求值。

FpnEvaluation(kexpr)
输入 : RPN表达式expr ( 由定语法正确 )
输出 : 表达式数值
{引入栈s,用以存放操作数;while (expr尚未扫描完毕) {从expr中读入下一元素x;if(x是操作数) 将x压入S;else   {//x是运算符从生S中弹出运算符x所需数目的操作数;对弹出的操作数实施x运算,并将运算结果重新压入s ;}//else            }//while返回栈项; //也是栈底
}

可见,除了一个辅助栈外,该算法不需要借助任何更多的数据结构。此外,算法的控制流程也十分简明,只需对RPN表达式做单向的顺序扫描,既无需更多判断,也不含任何分支或回溯。算法4.1的一次完整运行过程,如表4.3所示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NMo9VtAG-1630380009061)(/home/xz/图片/选区_175.png)]

试探回溯法

剪枝

为此,必须基于对应用问题的深刻理解, 利用问题本熏遇有的某些规律尽可能多、尽可能早地排除搜索空间中的候选解。其中一种重要的持巧就是,突据候选解的某些局部特征,以候选解子集为单位批量地排除。通常如图4.7所示,搜壶空间多呈树状结构,而被排除的候选解往往隶属于同一分支,故这一技巧也可以形象地称作剪枝“pruning) 。与之对应的算法多呈现为如下模式。从和雯开始,尝试从零开始,举试逐步增加候选解的长度。 更准确地,这一过程是在成批地考查具有特定前缀的所有候选解。这种从长度上逐步向目标解靠近的尝试,称作试探 (probing) .作为解的局部特征,特征前闪在试探的过程中一旦被发现与目标解不合,则收缩到此前一步的长度,然后继续试和供下一可能的组合。特征前缀长度缩减的这类操作,称作回溯(backtracking) ,其效果等同于前枝。如此,只要目标解的确存在就迟早会被发现,而且只要剪枝所依据的特征设计得当,计算的效率就会大大提高。

八皇后

如图4.8(a),国际象棋中皇后的势方范围覆盖其所在的水平线、垂直线以及两条对角线。现考查如下问题: 在nxn的棋盘上放置n个皇后,如何使得她们第此互不攻击一一此时称她人科构成一个可行的棋局。对于任何整数n > 4,这就是n皇后问题。由鸽巢原理可知, 在n行n列的棋盘上至多只能放置n个皇后。反之,n个时后在nxn棋盘上的可行棋局有通常也存在,比如图4.8(b)即为在8x8棋盘上,直8个旦后构成的一个可行棋局。

struct Queen   //皇后类
{int x, y; //皇后在棋盘上的位置坐标Queen ( int xx = 0, int yy = 0 ) : x ( xx ), y ( yy ) {};bool operator== ( Queen const &q ) const   //重载判等操作符,以检测不同皇后之间可能的冲突{return    ( x == q.x ) //行冲突(这一情况其实并不会发生,可省略)|| ( y == q.y ) //列冲突|| ( x + y == q.x + q.y ) //沿正对角线冲突|| ( x - y == q.x - q.y ); //沿反对角线冲突}bool operator!= ( Queen const &q ) const { return ! ( *this == q ); } //重载不等操作符 /*DSA*/可否写成:return *this != q?
};

可见,每个旺后对象均由其在棋盘上的位置坐标确定。此外,这里还通过重载判等操作符,实现了对皇后位置是否相互冲突的便捷判断。有具体地,这里按照以上棋规,将同行、同列或同对 角线的任意两个皇后视作“相等”,于是两个皇后相互冲突当且仅当二者被判作“相等”。

算法实现

基于试探回溯策略,可如代码4.9所示,实现通用的N皇后算法。既然每行能且仅能放置一个皇后, 故不妨首先将各皇后分配至每一行。然后, 从空要盘开始,逐个尝试着将她们放置到无冲突的某列。每放置好一个皇后,才继续试探下一个。若当前皇后在任何列都会造成冲突,则后续星后的试探都必将是徒劳的,故此时应该回潮到上一皇后。

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 ) );   //所有分支均已或穷举或剪枝之后,算法结束
}

这里借助栈solu来动态地记录各皇后的列号。当该栈的规模增至N时,即得到全局解。该栈即可和撤次给出各皇后在可行棋局中所处的位置。

实例

图4.9给出了利用以上算法,得到四皇后问题第一个解的完整过程。

首先试探第一行皇后,如图(a)所示将其暂置于第6列,同时列号入栈。接下来试探再第二 ,行星后,如图(b)所示在排除前两列后,将其暂置于第2列,同时列号入栈。然而此后试探第三行皇后时,如图©所示发现所有列均有冲突。于是回测到第二行,并如图(d)所示将第二行星后调整到第3列,同时更新栈顶列号。后续各步原理相同,吉至图(1)栈满时得到一个全局解。如此不断地试探和回潮,即可得到所有可行模局。可见,通过剪枝我们对原规模为4! = 24的搜索空间实现了有效的盘选。随着问题规模的增加,这一技巧的优化效果将更为明显。

迷宫寻径

路径规划是人工智能的基本问题之一,要求依照约定的行进规则, 在具有特定几何结构的空间区域内,找到从起点和到终点的一条通路。以下考查该问题的一个简化版本: 空间区域限定为由nx n个方格组成的迷宫,除了四周的围墙,还有分布其间的若干障碍物,只能水平或垂直移动。我们的任务是,在任意指定的起始格点与目标格点之间,找出一条通路《如时的确存在) 。

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]; //迷宫

可见,除了记录其位置坐标外,格点还需记录其所处的状态。共有四种可能的状态: 原始可用的(AVAILABLE)、在当前路径上的(ROUTE) 所有方向均尝试失败后回诅过的(BACKTRACKED)、不可穿越的(WALL) 。属于当前路径的格点,还需记录其前驱和后继格点的方向。既然只有上、下、左、右四个连通方向,故以EAST、SOUTH、WEST和NORTH区分。特别地,因尚未搜索到而仍处于初始AVAILABLE状态的格点,邻格的方向都是未知的〔UNKNOWN ) : 经过回涉后处于BACKTRACKED状态的格点,与邻格之间的连通关系均已关闭,故标记为NO_WMAY。

邻格查询

在路径试深过程中需反复确定当前位置的相邻格点,可如代码4. 11所示实现查询功能。

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 );}
}

邻格转入
在确认某一相邻格点可用之后,算法将朝对应的方向向前试探一步, 同时路径延长一个单元。为此,需如代码4.12所示实现相应的格点转入功能。

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;
}

算法实现

在以上功能的基础上,可基于试探回溯策略实现寻径息法如代码4.13所示。

// 迷宫寻径算法:在格单元s至t之间规划一条通路(如果的确存在)
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;
}

该问题的搜索过程中,局部解是一条源自起始格点的路径,它随着试探、回湖相应地伸长、缩短。因此,这里借助栈path按次序记录组成当前路径的所有格点,并动态地随着试探、回溯做入栈、出栈操作。路径的起始格点、当前的末端格点分别对应于path的栈底和栈项,当后着抵达目标格点时搜索成功,此时path所对应的路径即可作为全局解返回。

左侧为随机生成的13x13迷宫。算法启动时,其中格点分为可用 (AVAILABLE,白色) 与障碍〈WALL,黑色) 两种状态。在前一类中,随机指定了起始格点〈+) 和目标格点《〈$) 。中图为算法执行过程的某一时刻, 可见原先为可用状态的一些格点已经转换为新的状态: 转入ROUTE状态的格点,依次联接构成一条尚未完成的) 通路;曾参与构成通路但后因所有前进方向均已尝试完毕而回渭的格点,则进而从ROUTE转入TRIED状态《以圆圈注明) 。如右图所示,经过48步试探和6步回渭,最终找到一条长度为42的通路。通过这一实例亦可看出,在起点与终点之间的确彼此连通时,尽管这一算法可保证能够找出一条通路,但却未必是最短的 。

正确性

该算法会党试当前格点的所有相邻格点, 因此通过数学归纳可知, 若在找到全局解后依然继续查找,则该算法可以抵达与起始格点连通的所有格点。因此,只要目标格点与起始格点的确相互连通,则这一算法必将如右图所示找出一条联接于二者之间的通路。从算法的中间过程及最终结果都可清晰地看出, 这里用以记录通路的栈结构的确相当于忒修斯手中的线强,它确保了算法可沿着正确地方向回沽。另外,这里给所有回泪格点所做的状态标记则等效于用粉笔做的记号,正是这些标记确保了格点不致被重复搜索,从而有效地避免了沿环路的死循环现象。

复杂度

​ 算法的每一步姑代仅需常数时间,故总体时间复杂度线性正比于试探、回溯操作的总数。由
于每个格点至多参与试探和回溯各一次,故亦可度量为所有被访问过的格点总数一一在图4.16
中,也就是最终路径的总长度再加上圆圈标记的数目。

队列

与栈一样,队列〈queue) 也是存放数据对象的一种容器,其中的数据对象也接线性的光辑次序排列。队列结构同祥支持对象的岳入和删除,但丙种操作的范围分别被限制于队列的两端一一若约定新对象只能从某一端插入其中, 则只能从另一端删除已有的元素。允许取出元素的一端称作队头〈front) ,而允许插入元素的另一端称作队尾〈rear) 。以如图4.11所示顺序妨放羽毛球的球棚为例。通常,我们总是从球托所指的一端将球取出,而从另一端把球纳入桶中。因此如果将球托所指的一端理解为队头,另一端理解为队尾,则桶中的羽毛球即构成一个队列,其中每只球都属于该队列的一个元素。

Queue模板类

既然队列也可视作序列的特例, 故只要将队列作为列表的派生类, 即可利用C++的继承机制,基于3.2.2节已实现的列表模板类,实现队列结构。同样地,也需要按照队列的习惯对各相关的接口重新合名,。按照才4.4所列的ADT接口,可撕述并实现oueue模板类如下。

#include "../List/List.h" //以List为基类
template <typename T> class Queue: public List<T>   //队列模板类(继承List原有接口)
{public: //size()、empty()以及其它开放接口均可直接沿用void enqueue ( T const &e ) { insertAsLast ( e ); } //入队:尾部插入T dequeue() { return remove ( first() ); } //出队:首部删除T &front() { return first()->data; } //队首
};
#include "queue_implementation.h"

由代码4.14可见,队列的enqueue( )操作等效于将新元素作为列表的末元素宪入,dequeue() 操作风竺效于删除列表的首元素,front()近作可直接返回对列表首元素的引用。而size()及empty()等接口,均可直汪沿用基类的同名瘘口。这里插入和删除操作的位置分别限制于列表的末端和首端,故由3.3.5节的分析结论可知,队列结构以上接口的时间复杂度均为常数。套用以上思路,也可直接基于2.2.3节的Vector模板类派生出Queue类〈习题[4-2]) 。

队列应用

循环分配器

为在客户〈《client) 群体中共享的某一资源【比如多个应用程序共享同一CPU) ,一套公平胖高效的分配规则必不可少,而队列结构则非常适于定义和实现这样的一套分配规则 。县人地,可以借助队列Q实现一个资源循环分配器,其航体流程大致如管法4.2所示。

RoundRobin //
{Queue Q(clients);while(!ServiceClosed())   {e = Q.dequeue();server(e);Q.enqueue(e);}
}

在以上所谓轮值 (round robin) 算法中,首先令所有参与资源分本的客户组成一个队列Q。接下来是一个反复轮回式的调度过程: 取出当前位于队头的客户,将资源交子该客户使用; 在经过固定的时间之后,回收资源,并令该客户重新入队。得益于队列“先进先出”的特性,如此既可在所有客户之间达成一种均衡的公平,也可使得资源得以充分利用。这里, 每位客户持续占用资源的时间,对该算法的成败至关重要。一方面, 为保证响应速度,这一时间值通常都不能过大。另一方面,因占有权的切换也需要耗费一定的时间,故若该时间值取得过小,切换过于频繁,又会造成整体效率的下降。因此,往往需要通过实测确定最佳值。反过来,在单一客户使用多个资源的场台,队列也可用以保证资源的均衡使用,提高整体使用效率。针式打印机配置的色带,即是这样的一个实例,环形?色带收纳于两端开口的色带盒内。在打印过程中,从一端不断卷出的色带,在经过打印头之后,又从另一端重新卷入盒中,并如此往复。可见,此处色带盒的功能等效于一个队列,色带的各部分按照“先进先出”的原则被均衡地使用,整体使用寿命因而得以延长。

struct Customer { int window; unsigned int time; }; //顾客类,所属窗口(队列)、服务时长

顾客在银行中接受服务的整个过程,可由如代码4.16所示的simulate()函数模拟。

void simulate ( int nWin, int servTime )   //按指定窗口数、服务总时间模拟银行业务
{Queue<Customer> *windows = new Queue<Customer>[nWin]; //为每一窗口创建一个队列for ( int now = 0; now < servTime; now++ )   //在下班之前,每隔一个单位时间{if ( rand() % ( 1 + nWin ) )   //新顾客以nWin/(nWin + 1)的概率到达{Customer c ; c.time = 1 + rand() % 98; //新顾客到达,服务时长随机确定c.window = bestWindow ( windows, nWin ); //找出最佳(最短)的服务窗口/*DSA*/ToDo: 更精细的策略windows[c.window].enqueue ( c ); //新顾客加入对应的队列}for ( int i = 0; i < nWin; i++ ) //分别检查if ( !windows[i].empty() ) //各非空队列if ( -- windows[i].front().time <= 0 ) //队首顾客的服务时长减少一个单位windows[i].dequeue(); //服务完毕的顾客出列,由后继顾客接替/*DSA*/displayProgress ( windows, nWin, now ); //显示当前各(窗口)队列情况/*DSA*/delay > 0 ? //若命令行指定的时间间隔为正数/*DSA*/  _sleep ( delay ) : //则做相应的延迟/*DSA*/  getchar(); //否则,以手动方式单步演示} //fordelete [] windows; //释放所有队列(此前,~List()会自动清空队列)
}

这里, 首先根据银行所设窗口的数量相应地建立多个队列.以下以单位时间为间隔反复狗代,直至下班。每一时刻都有一位顾客按一定的概率抵达,随机确定所办业务服务时长之后,归入某一“最优”队列。每经单位时间,各队列最靠前顾客《如果有的话) 的待服务时长均相应减少一个单位。若时长归零,则意味着该顾客的业务已办理完毕,改应退出队列并由后一位顾客〈如果有的话) 接替。可见,顾客归入队列和退出队列的事件可分别下enqueue()和dequeue()操作模拟,查询并修改队首顾客时长的事件则可由front( 1)操作模拟。

int bestWindow ( Queue<Customer> windows[], int nWin )   //为新到顾客确定最佳队列
{int minSize = windows[0].size(), optiWin = 0; //最优队列(窗口)for ( int i = 1; i < nWin; i++ ) //在所有窗口中if ( minSize > windows[i].size() ) //挑选出{ minSize = windows[i].size(); optiWin = i; } //队列最短者return optiWin; //返回
}

邓俊辉数据结构学习笔记2相关推荐

  1. 邓俊辉数据结构学习笔记3-二叉树

    二叉树及其表示 树 有根树 从图论的角度看,树等价于连通无环图.因此与一般的图相同,树也由一组项点〈vertex)以及联接与其间的若干条边〈edge) 组成.在计算机科学中,往往还会在此基础上,再指定 ...

  2. 清华邓俊辉数据结构学习笔记(4) - 二叉搜索树、高级搜索树

    第七章 二叉搜索树 (a)概述 循关键码访问:数据项之间,依照各自的关键码彼此区分(call-by-key),条件是关键码之间支持大小比较与相等比对,数据集合中的数据项统一地表示和实现为词条entry ...

  3. 邓俊辉数据结构学习笔记1

    起泡排序算法 void bubblesort1A(int A[], int n) //起泡排序算法(版本1A):0 <= n {int cmp = 0, swp = 0;bool sorted ...

  4. 清华邓俊辉数据结构学习笔记(3) - 二叉树、图

    第五章 二叉树 (a)树 树能够结合向量的优点(search)和列表的优点(insert.remove),构成List< List >. 有根树 树是特殊的图 T = (V, E),节点数 ...

  5. 清华大学邓俊辉-数据结构MOOC笔记-树的概念及逻辑表示

    清华大学邓俊辉-数据结构MOOC笔记-树的概念及逻辑表示 有关概念: 与图论略有不同,数据结构中的树:1.需要为每一颗树指定一个特殊的顶点,作为"根"(root),对应rooted ...

  6. 邓俊辉数据结构学习心得系列——如何正确衡量一个算法的好坏

    数据结构这门课主要关注如何设计合理的数据结构和算法,来简化时间复杂度和空间复杂度. 想要科学的解决这样一个优化的问题,最核心的思想也是最基础的,就是要量化问题.这也是将数学运用在实际问题中的一个基石. ...

  7. 邓俊辉数据结构学习心得系列——数据结构中所研究的算法

    写在前面的话: 本文只是个人学习邓俊辉老师C++数据结构的整理,包含了很多个人的见解(从内容到材料的组织形式).所整理的内容不保证逻辑性和完整性,仅供参考. 算法的基本性质: 有正确的输入 有正确的输 ...

  8. 邓俊辉数据结构学习-3-栈

    栈的学习 栈的应用场合 逆序输出 输出次序与处理过程颠倒,递归深度和输出长度不易预知 不是很理解 实例:进制转换 大致思路:对于进制转换,我们一般使用的都是长除法,因此要保存每次得到的余数,但是最后算 ...

  9. 邓俊辉数据结构学习-7-BST

    二叉搜索树(Binary-Search-Tree)--BST 要求:AVL树是BBST的一个种类,继承自BST,对于AVL树,不做太多掌握要求 四种旋转,旋转是BBST自平衡的基本,变换,主要掌握旋转 ...

最新文章

  1. IDEA只修改代码提示为不区分大小写
  2. laravel 框架的 csrf
  3. webpack之externals操作三部曲--正确的姿势
  4. Linux 异步通知
  5. java IoT物联网server 读取javascript协议配置文件
  6. Ctrl+F5和F5区别
  7. spring 事务_极限 Spring (4) Spring 事务
  8. CrossOver如何删除容器软件的安装包
  9. 【Visio】 windows Visio 画图
  10. 【Ubuntu】安装H.264解码器
  11. 工厂模式简介和应用场景
  12. linux下filezilla使用教程,FTP工具filezilla使用教程
  13. D-月之暗面(树形dp)
  14. php7 memcached sasl,memcached sasl
  15. 吴晓波:拼多多的新与旧
  16. 英语中的逻辑思维真奇妙
  17. 【云原生】-Docker部署SQL Server及最佳应用
  18. Storyboard 之segue用法总结
  19. Java实现O(nlogn)最长上升子序列
  20. java 面试概念题 笔记

热门文章

  1. Android Studio Error:前言中不允许有内容
  2. 线性内插interp1函数用法
  3. B站尚硅谷React入门教程
  4. MiniGui打开GridView控件
  5. linux debian vi,debian系统中常用的vi命令使用和讲解
  6. 最好用的矢量绘图软件Sketch mac中文72.3
  7. echarts折线图曲线,每个值上面添加小圆点或者小圆圈
  8. jvm原理与性能调优
  9. L1正则化降噪,对偶函数的构造,求解含L1正则项的优化问题,梯度投影法
  10. tp5 分页之无刷新页面渲染