[数据结构与算法].王晓东.文字版

jyn1078199840

贡献于2013-05-18

字数:0 关键词:

书书书 职业技术教育软件人才培养模式改革项目成果教材 数据结构与算法 王晓东! 编 高等教育出版社 内容提要 本书是职业技术教育软件人才培养模式改革项目成果教材之一,主要内容包括数据结构和算法的基本概念 如表、栈、队列、递归、排序与选择、树、集合、符号表、字典、优先队列、并查集和图等。 为了适应培养我国 !" 世纪计算机各类人才的需要,结合我国高等学校教育工作的现状,立足培养学生能跟 上国际计算机科学技术的发展水平,更新教学内容和教学方法,本书以基本数据结构和算法设计策略为知识单 元系统地介绍数据结构知识与应用、计算机算法的设计与分析方法,为计算机学科的学生提供一个广泛坚实的 数据结构与算法设计基础知识。 本书适用于高等职业学校、高等专科学校、成人高校、独立设置的软件职业技术学院、本科院校及举办的二 级职业技术学院、教育学院以及民办高校使用,不仅可用作高等院校计算机科学与工程专业学生学习数据结构 与算法的教材,而且也适合广大工程技术人员和自学读者学习参考。 ! 图书在版编目("#$)数据 # 数据结构与算法$ 王晓东编 % — 北京:高等教育出版 社,!&&’% "" # ()*+ , - &. - &"’!&. - . # !% 数 % % % # "% 王 % % % # #% $数据结构 - 教材%算法 分析 - 教材# &% /0’""% "! # 中国版本图书馆 1(0 数据核字(!&&’)第 &23’’" 号 出版发行# 高等教育出版社 购书热线# &"&45.&6.633 社# # 址# 北京市西城区德外大街 . 号 免费咨询# 3&&43"&4&623 邮政编码# "&&&"" 网# # 址# 7889:$ $ :::; 7<9; <=>; ?@ 总# # 机# &"&43!&!3322 # # # # # 7889:$ $ :::; 7<9; ?AB; ?@ 经# # 销# 新华书店北京发行所 印# # 刷 开# # 本# ,3, C "&2!# " $ "5 版# # 次# # # 年# 月第 " 版 印# # 张# ",% !6 印# # 次# # # 年# 月第# 次印刷 字# # 数# .!& &&& 定# # 价# !"% 3& 元 本书如有缺页、倒页、脱页等质量问题,请到所购图书销售部门联系调换。 版权所有# 侵权必究 策划编辑! 冯! 英 责任编辑! 关! 旭 封面设计! 王凌波 责任绘图! 黄建英 版式设计! 马静如 责任校对! 存! 怡 责任印制! ! ! ! 职业技术教育软件人才培养模式改革项目 成果教材编审委员会 主! 任! 朱之文 委! 员! (按姓氏笔划为序) 马肖风! 王! 珊! 田本和! 叶东毅! 冯伟国 刘志鹏! 李堂秋! 郑祖宪! 高! 林! 黄旭明 出 版 说 明 信息产业是国民经济和社会发展基础性、战略性产业。加快发展信息技术和信息产业,以信 息化带动工业化,以信息化促进工业化,是当前和今后我国产业结构调整发展的战略重点。软件 产业是信息产业的核心,加快软件人才培养是加快软件产业发展的先决条件。为适应经济结构 战略性调整及软件产业发展的需要,加快培养各类软件应用性人才,在国家改革和发展委员会、 教育部的指导和支持下,福建省从 !""! 年开始,在全国率先举办软件类高等职业技术教育,拟以 办学模式和人才培养模式改革为重点,积极探索有水平、有质量、有特色的软件高职教育发展的 新路子。 在软件类高等职业技术教育改革和建设过程中,福建省坚持教育创新,把改革教学内容和课 程体系,加强专业建设、教材建设和教学队伍建设作为工作的重点。目前,根据软件行业发展趋 势、就业环境和软件高等职业技术教育的办学特点,经组织专家论证和审定,福建省高校首批开 设了可视化编程、#$% 应用程序设计、软件测试、网络系统管理员、网络构建技术、数据库管理 员、图形& 图像制作、多媒体制作、计算机办公应用等 ’ 个软件高职专业,制订了较为科学合理的 人才培养方案。为配合支持软件类高职教育的改革和建设,福建省教育厅聘请软件教育有关专 家、学者和著名软件企业的高级工程技术人员成立了“职业技术教育软件人才培养模式改革项 目成果教材编审委员会”,以“抓好试点规划,实施精品战略”为指导方针,认真吸取国内外软件 技术发展成果,根据软件企业对人才培养提出的新要求和软件高职的办学特点,认真处理好教材 的统一性与多样化、基本教材与辅助教材、学历教育教材与认证培训教材的关系,以组织开展软 件高职公共基础课、专业基础课和专业主干课教材的建设为重点,同时扩大品种,实现教材系列 配套,在此基础上形成特色鲜明、优化配套的软件高等职业技术教育教材体系。 本软件系列教材适用于本科院校、高职高专院校、成人高校及继续教育学院的软件高职类专 业及相关专业使用。 职业技术教育软件人才培养模式改革项目成果教材编审委员会 二!!三年五月 前! ! 言 为了以最少的成本、最快的速度、最好的质量开发出适合各种应用需求的软件,软件设计人 员在软件开发过程中必须遵循软件工程的原则。一个高效的程序不仅需要“编程小技巧”,它更 需要合理的数据组织和清晰高效的算法,这正是计算机科学领域里数据结构与算法设计所研究 的主要内容。一些著名的计算机科学家在有关计算机科学教育的论述中认为,计算机科学是一 种创造性思维活动,其教育必须面向设计。数据结构与算法正是一门面向设计,且处于计算机学 科核心地位的教育课程。通过对数据结构与算法的系统学习与研究,理解和掌握算法设计的主 要方法,培养对算法的计算复杂性进行正确分析的能力,对从事计算机系统结构、系统软件和应 用软件研究与开发的科技工作者是非常重要和必不可少的。为了适应培养我国 "# 世纪计算机 各类人才的需要,结合我国高等学校教育工作的现状,立足培养学生跟上国际计算机科学技术的 发展水平,更新教学内容和教学方法,本书以基本数据结构和算法设计策略为知识单元系统地介 绍数据结构知识与应用、计算机算法的设计与分析方法,以期为计算机学科的学生提供一个广泛 坚实的数据结构与算法设计基础知识。 全书共分 #$ 章,首先在第 # 章中介绍了数据结构、抽象数据类型和算法的基本概念,接着对 算法的计算复杂性和算法的描述作了简要的阐述。然后以抽象数据类型为主线索,围绕设计算 法常用的基本数据结构和基本设计策略组织了第 " 章 % 第 #$ 章的内容。 第 " 章 % 第 & 章依次介绍基于序列的抽象数据类型表、栈和队列。 第 ’ 章介绍了递归的概念,以及递归在数据结构和算法设计中的广泛应用,并详细阐述了分 治法、动态规划和回溯法这 $ 个实践中常用的使用递归技术的算法设计策略。 第 ( 章介绍在实际应用中常用的排序与选择算法。 第 ) 章讨论反映层次关系的抽象数据类型树。 第 * 章讨论表示集合的抽象数据类型。 第 + 章讨论抽象数据类型符号表以及散列表等实践中常用实现符号表的方法。 第 #, 章的主题是以有序集为基础的抽象数据类型字典及其实现方法。讨论了实现字典的 二叉搜索树以及 -./ 树等高效算法。 第 ## 章讨论以集合为基础的抽象数据类型优先队列及其实现方法。 第 #" 章讨论以不相交的集合为基础的抽象数据类型并查集及其实现方法。 最后,在第 #$ 章介绍非线性结构图及图的算法。 为了加深对知识的理解,各章配有难易适当的习题,以适应不同程度读者练习的需要。 由于作者的知识和写作水平有限,书稿虽几经修改,仍难免有缺点和错误。热忱欢迎同行专 家和读者的批评指正,以使本书在使用中不断改进,日臻完善。 在本书的编写过程中,福州大学“!"" 工程”计算机与信息工程重点学科实验室为本书的写 作提供了优良的设备与工作环境。傅清祥教授在百忙之中认真审阅了全书,提出了许多宝贵的 改进意见。在此,谨向每一位曾经关心和支持本书编写工作的各方面人士表示衷心的谢意! 作者 !##$ 年 % 月 ! 前& & 言 书书书 目! ! 录 第 ! 章" 引论 "⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! "# "! 算法及其复杂性的概念 "⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# "# "! 算法与程序 "⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# "# $! 算法复杂性的概念 $⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# "# %! 算法复杂性的渐近性态 %⋯ ⋯ ⋯ ⋯ ⋯ ! "# $! 算法的表达与数据表示 &⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# $# "! 问题求解 &⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# $# $! 表达算法的抽象机制 &⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! "# %! 抽象数据类型 ’⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# %# "! 抽象数据类型的基本概念 ’⋯ ⋯ ⋯ ⋯ ! ! "# %# $! 使用抽象数据类型的好处 "(⋯ ⋯ ⋯ ⋯ ! "# )! 数据结构、数据类型和抽象数据 类型 "(⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! "# &! 用 * 语言描述数据结构与算法 ""⋯ ! ! "# &# "! 变量和指针 ""⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# &# $! 函数与参数传递 "$⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# &# %! 结构 "%⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# &# )! 动态存储分配 ")⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 本章小结 "+⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 习题 "+⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ 第 # 章" 表 "’⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! $# "! ,-. 表 "’⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! $# $! 用数组实现表 "/⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! $# %! 用指针实现表 $)⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! $# )! 用间接寻址方法实现表 $’⋯ ⋯ ⋯ ⋯ ⋯ ! $# &! 用游标实现表 %"⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! $# +! 循环链表 %0⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! $# 0! 双链表 )(⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! $# ’! 表的搜索游标 ))⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! $# ’# "! 用数组实现表的搜索游标 )&⋯ ⋯ ⋯ ⋯ ! ! $# ’# $! 单循环链表的搜索游标 )+⋯ ⋯ ⋯ ⋯ ⋯ ! $# /! 应用 )’⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 本章小结 )/⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 习题 )/⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ 第 $ 章" 栈 &$⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! %# "! ,-. 栈 &$⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! %# $! 用数组实现栈 &%⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! %# %! 用指针实现栈 &+⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! %# )! 应用 &’⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 本章小结 +"⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 习题 +"⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ 第 % 章" 队列 +%⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! )# "! ,-. 队列 +%⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! )# $! 用指针实现队列 +)⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! )# %! 用循环数组实现队列 +0⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! )# )! 应用 0"⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 本章小结 0&⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 习题 0&⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ 第 & 章" 递归 00⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! &# "! 递归的概念 00⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! &# $! 递归程序设计 ’%⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! &# $# "! 分治与递归 ’%⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! &# $# $! 动态规划 ’)⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! &# $# %! 回溯与递归 /"⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! &# %! 模拟递归 /%⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! &# )! 应用 /+⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 本章小结 //⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 习题 //⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ 第 ’ 章" 排序与选择 "("⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! +# "! 简单排序算法 "("⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! +# "# "! 冒泡排序 "($⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! +# "# $! 插入排序 "(%⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! +# "# %! 选择排序 "(%⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! +# "# )! 简单排序算法的计算复杂性 "()⋯ ⋯ ! "# $! 快速排序算法 %&’⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# $# %! 算法基本思想及实现 %&’⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# $# $! 算法的性能 %&"⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# $# (! 随机快速排序算法 %&)⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# $# *! 非递归快速排序算法 %&)⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# $# ’! 三数取中划分算法 %&+⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# $# "! 三划分快速排序算法 %%&⋯ ⋯ ⋯ ⋯ ⋯ ! "# (! 合并排序算法 %%%⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# (# %! 算法基本思想及实现 %%%⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# (# $! 对基本算法的改进 %%$⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# (# (! 自底向上的合并排序算法 %%(⋯ ⋯ ⋯ ! ! "# (# *! 自然合并排序 %%(⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# (# ’! 链表结构的合并排序算法 %%*⋯ ⋯ ⋯ ! "# *! 线性时间排序算法 %%’⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# *# %! 计数排序 %%"⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# *# $! 桶排序 %%)⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! "# ’! 中位数与第 ! 小元素 %%,⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# ’# %! 平均情况下的线性时间选择 算法 %%,⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "# ’# $! 最坏情况下的线性时间选择 算法 %%+⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! "# "! 应用 %$%⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 本章小结 %$(⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 习题 %$(⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ 第 ! 章" 树 %$’⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! )# %! 树的定义 %$’⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! )# $! 树的遍历 %$)⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! )# (! 树的表示法 %$+⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! )# (# %! 父结点数组表示法 %$+⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! )# (# $! 儿子链表表示法 %(&⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! )# (# (! 左儿子右兄弟表示法 %(&⋯ ⋯ ⋯ ⋯ ⋯ ! )# *! 二叉树 %(%⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! )# ’! -./ 二叉树 %((⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! )# "! 二叉树的实现 %((⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! )# "# %! 二叉树的顺序存储结构 %((⋯ ⋯ ⋯ ⋯ ! ! )# "# $! 二叉树的结点度表示法 %(’⋯ ⋯ ⋯ ⋯ ! ! )# "# (! 用指针实现二叉树 %(’⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! )# )! 线索二叉树 %*&⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! )# ,! 应用 %*$⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 本章小结 %*"⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 习题 %*"⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ 第 # 章" 集合 %*,⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ,# %! 以集合为基础的抽象数据类型 %*,⋯ ! ! ,# %# %! 集合的定义和记号 %*,⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! ,# %# $! 定义在集合上的基本运算 %*+⋯ ⋯ ⋯ ! ,# $! 用位向量实现集合 %’&⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ,# (! 用链表实现集合 %’(⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ,# *! 应用 %’)⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 本章小结 %’,⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 习题 %’,⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ 第 $ 章" 符号表 %"&⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! +# %! 实现符号表的简单方法 %"&⋯ ⋯ ⋯ ⋯ ⋯ ! +# $! 用散列表实现符号表 %"$⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! +# $# %! 开散列 %"$⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! +# $# $! 闭散列 %"*⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! +# $# (! 散列函数及其效率 %"+⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! +# $# *! 闭散列的重新散列技术 %)&⋯ ⋯ ⋯ ⋯ ! +# (! 应用 %)&⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 本章小结 %)$⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 习题 %)$⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ 第 %& 章" 字典 %)*⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! %&# %! 字典的定义 %)*⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! %&# $! 用数组实现字典 %)’⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! %&# (! 用二叉搜索树实现字典 %)’⋯ ⋯ ⋯ ⋯ ! %&# *! -01 树 %,(⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! %&# *# %! -01 树的定义和性质 %,*⋯ ⋯ ⋯ ⋯ ⋯ ! ! %&# *# $! 旋转变换 %,’⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! %&# *# (! -01 树的插入运算 %,,⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! %&# *# *! -01 树的删除运算 %+%⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! %&# ’! 应用 %+*⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 本章小结 %+"⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 习题 %+"⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ 第 %% 章" 优先队列 %+,⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! %%# %! 优先队列的定义 %+,⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! %%# $! 用字典实现优先队列 %++⋯ ⋯ ⋯ ⋯ ⋯ ! %%# (! 优先级树和堆 %++⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 目! ! 录 ! ""# $! 用数组实现堆 %&"⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ""# ’! 可并优先队列 %&$⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! ""# ’# "! 左偏树的定义 %&$⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! ""# ’# %! 用左偏树实现可并优先队列 %&’⋯ ⋯ ! ""# (! 应用 %&)⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 本章小结 %"*⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 习题 %"*⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ 第 !" 章# 并查集 %"’⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! "%# "! 并查集的定义及其简单实现 %"’⋯ ⋯ ! "%# %! 用父亲数组实现并查集 %"+⋯ ⋯ ⋯ ⋯ ! "%# *! 应用 %%&⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 本章小结 %%%⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 习题 %%%⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ 第 !$ 章# 图 %%$⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! "*# "! 图的基本概念 %%$⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! "*# %! 抽象数据类型 ,-. 图 %%+⋯ ⋯ ⋯ ⋯ ⋯ ! "*# *! 图的表示法 %%/⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "*# *# "! 邻接矩阵表示法 %%/⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "*# *# %! 邻接表表示法 %%)⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "*# *# *! 紧缩邻接表 %%)⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! "*# $! 用邻接矩阵实现图 %*&⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "*# $# "! 用邻接矩阵实现赋权有向图 %*&⋯ ⋯ ! ! "*# $# %! 用邻接矩阵实现赋权无向图 %**⋯ ⋯ ! ! "*# $# *! 用邻接矩阵实现有向图 %**⋯ ⋯ ⋯ ⋯ ! ! "*# $# $! 用邻接矩阵实现无向图 %*$⋯ ⋯ ⋯ ⋯ ! "*# ’! 用邻接表实现图 %*’⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "*# ’# "! 用邻接表实现有向图 %*’⋯ ⋯ ⋯ ⋯ ⋯ ! ! "*# ’# %! 用邻接表实现无向图 %*/⋯ ⋯ ⋯ ⋯ ⋯ ! ! "*# ’# *! 用邻接表实现赋权有向图 %*)⋯ ⋯ ⋯ ! ! "*# ’# $! 用邻接表实现赋权无向图 %$*⋯ ⋯ ⋯ ! "*# (! 图的遍历 %$$⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "*# (# "! 广度优先搜索 %$$⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "*# (# %! 深度优先搜索 %$(⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! "*# +! 最短路径 %$/⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "*# +# "! 单源最短路径 %$/⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "*# +# %! 所有顶点对之间的最短 路径 %’"⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! "*# /! 最小支撑树 %’*⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "*# /# "! 最小支撑树性质 %’*⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "*# /# %! 0123 算法 %’*⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! ! "*# /# *! 4156789 算法 %’(⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! "*# )! 图匹配 %’/⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 本章小结 %(&⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ! 习题 %(&⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ 参考文献 %(*⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ !目! ! 录 书书书 第 ! 章" 引" " 论 学习目标 ! 理解算法的概念。 ! 理解什么是程序,程序与算法的区别和内在联系。 ! 能够列举求解问题的基本步骤。 ! 掌握算法在最坏情况、最好情况和平均情况下的计算复杂性概念。 ! 掌握算法复杂性的渐近性态的数学表述。 ! 了解表达算法的抽象机制。 ! 熟悉抽象数据类型的基本概念。 ! 熟悉数据类型和数据结构的概念。 ! 理解数据结构、数据类型和抽象数据类型三者的区别和联系。 ! 掌握用 ! 语言描述数据结构与算法的方法。 "# "$ 算法及其复杂性的概念 !# !# !" 算法与程序 对于计算机科学来说,算法(%&’()*+,-)的概念是至关重要的。例如在一个大型软件系统的 开发中,设计出有效的算法将起决定性的作用。通俗地讲,算法是指解决问题的一种方法或一个 过程。严格地讲,算法是由若干条指令组成的有穷序列,且满足下述几条性质: ! 输入:有零个或多个由外部提供的量作为算法的输入。 " 输出:算法产生至少一个量作为输出。 # 确定性:组成算法的每条指令是清晰的、无歧义的。 $ 有限性:算法中每条指令的执行次数是有限的,执行每条指令的时间也是有限的。 程序(!"#$"%&)与算法不同。程序是算法用某种程序设计语言的具体实现,程序可以不满足 算法的性质(’)。例如操作系统,它是一个在无限循环中执行的程序,因而不是一个算法,然而 可把操作系统的各种任务看成是一些单独的问题,每一个问题由操作系统中的一个子程序通过 特定的算法来实现,该子程序得到输出结果后便终止。 !" !" #$ 算法复杂性的概念 一个算法的复杂性的高低体现在运行该算法所需要的计算机资源的多少上,所需要的资源 越多,就说该算法的复杂性越高;反之,所需要的资源越少,就说该算法的复杂性越低。计算机的 资源,最重要的是时间和空间(即存储器)资源。因此,算法的复杂性有时间复杂性和空间复杂 性之分。不言而喻,对于任意给定的问题,设计出复杂性尽可能低的算法是设计算法时追求的一 个重要目标。另一方面,当给定的问题已有多种算法时,选择其中复杂性最低者,是选用算法应 遵循的一个重要准则。因此,算法复杂性分析对算法的设计或选用有重要的指导意义和实用价 值。确切地说,算法的复杂性是运行算法所需要的计算机资源的量。需要的时间资源的量称为 时间复杂性;需要的空间资源的量称为空间复杂性。这个量应该集中反映算法的效率,而从运行 该算法的实际计算机中抽象出来,换句话说,这个量应该是只依赖于算法要解的问题的规模和算 法的输入函数。如果分别用 ! 和 " 表示算法要解的问题的规模和算法的输入,而且用 # 表示复 杂性,那么,应该将算法复杂性表示为 #(!,")。如果把时间复杂性和空间复杂性分开,并分别用 $ 和 % 来表示,那么应该有:$ & $(!,")和 % & %(!,")。由于时间复杂性与空间复杂性概念类 同,计量方法相似,且空间复杂性分析相对简单些,所以本书将主要讨论时间复杂性。现在的问 题是如何将复杂性函数具体化,即对于给定的 ! 和 ",如何导出 $(!,")和 %(!,")的数学表达式, 给出计算 $(!,")和 %(!,")的法则。下面以 $(!,")为例,将复杂性函数具体化。 根据 $(!,")的概念,它应该是算法在一台抽象的计算机上运行所需要的时间。设此抽象的 计算机所提供的元运算有 ’ 种,分别记为 (( ,() ,⋯ ,(’ 。又设每执行一次这些元运算所需要的 时间分别为 )( ,)) ,⋯ ,)’ 。对于给定的算法 *,设经统计,用到元运算 (+ 的次数为 ,+ ,+ & (,),⋯ ,’ 。 很清楚,对于每一个 +,( " + " ’ ,,+ 是 ! 和 " 的函数,即 ,+ & ,+(!,")。那么有 $(!,") & # ’ + & ( )+ ,+(!,"),其中 )+ ,+ * (,),⋯ ,’,是与 ! 和 " 无关的常数。 显然,不可能对规模为 ! 的每一种合法的输入 " 都统计 ,+(!,"),+ & (,),⋯ ,’ 。因此 $(!, ")的表达式还得进一步简化,或者说,只能在规模为 ! 的某些或某类有代表性的合法输入中统计 相应的 ,+ ,+ & (,),⋯ ,’ ,并评价时间复杂性。 通常考虑 + 种情况下的时间复杂性,即最坏情况、最好情况和平均情况下的时间复杂性,并 分别记为 $&%,(!)、$&-.(!)和 $%/$(!)。在数学上有 $&%,(!)& &%, "$-! $(!,")& &%, "$-! # ’ + & ( )+ ,+(!,")& # ’ + & ( )+ ,+(!,"% )& $(!,"% ) $&-.(!)& &-. "$-! $(!,")& &-. "$-! # ’ + & ( )+ ,+(!,")& # ’ + & ( )+ ,+(!,&")& $(!,&") $%/$(!)& #"$-! .(")$(!,")& #"$-! .(")# ’ + & ( )+ ,+(!,") ! 数据结构与算法 其中,!" 是规模为 " 的合法输入的集合;#% 是 !" 中一个使 $(",#% )达到 $!"#(")的合法输入;&# 是 !" 中一个使 $(",&#)达到 $!$%(")的合法输入;而 %(#)是在算法的应用中出现输入 # 的概率。 以上 & 种情况下的时间复杂性各从某一个角度来反映算法的效率,各有各的局限性,也各有 各的用处。实践表明,可操作性最好且最有实际价值的是最坏情况下的时间复杂性。本书对算 法时间复杂性的分析主要采用最坏情况下时间复杂性分析。 !" !" #$ 算法复杂性的渐近性态 随着经济的发展、社会的进步、科学研究的深入,要求用计算机解决的问题越来越复杂,规模 越来越大。对求解这类问题的算法作复杂性分析具有特别重要的意义,因而要特别关注。为此, 要引入复杂性渐近性态的概念。设 $(")是前面所定义的关于算法 & 的复杂性函数。一般说来, 当 " 单调增加且趋于’ 时,$( ")也将单调增加趋于’ 。对于 $( "),如果存在 ’$( "),使得当 "(’ 时有 $(")’ ’$(") $(") ( (,那么,就说 ’$(")是 $(")当 "(’ 时的渐近性态,或称 ’$(")为算 法 & 当 "(’ 的渐近复杂性而与 $(")相区别。因为在数学上,’$(")是 $(")当 "(’ 时的渐近 表达式。直观上,’$(")是 $(")中略去低阶项所留下的主项。所以它无疑比 $(")来得简单。例 如当 $(")) &"* + ,"-./" + 0! 时 ’$(")的一个答案是 &"* ,因为这时有 -$! "(( $(")’ ’$(") $(") ) -$! "(( ,"-./" * 0 &"* * ,"-./" * 0 ) ( 1 1 显然 &"* 比 &"* * ,"-./" * 0 简单得多。 由于当 "(’ 时 $(")渐近于 ’$("),有理由用 ’$(")来替代 $(")作为算法 & 在 "(’ 时的复 杂性的度量。而且由于 ’$(")明显比 $(")简单,这种替代是对复杂性分析的一种简化。进一步, 考虑到分析算法的复杂性的目的在于比较求解同一问题的 * 个不同算法的效率。而当要比较的 * 个算法的渐近复杂性的阶不相同时,只要能确定出各自的阶,就可以判定哪一个算法的效率 高。换句话说,这时的渐近复杂性分析只要关心 ’$(")的阶就够了,不必关心包含在 ’$(")中的常 数因子。所以,常常又对 ’$(")的分析进一步简化,即假设算法中用到的所有不同的元运算各执 行一次所需要的时间都是一个单位时间。 综上所述,已经给出了简化算法复杂性分析的方法,即只要考察当问题的规模充分大时,算 法复杂性在渐近意义下的阶。本书的算法分析都将这么做。与此简化的复杂性分析相配套,需 要引入以下渐近复杂性的记号。 以下设 +(")和 ,(")是定义在正数集上的正函数。 如果存在正的常数 - 和自然数 "( ,使得当 ")"( 时有 +(")" -,("),则称函数 +(")当 " 充 分大时上有界,且 ,(")是它的一个上界,记为 +(") ) .(,("))。这时还说 +(")的阶不高于 ,(")的阶。 举几个例子: ! 因为对所有的 ")2 有 &"",",从而有 &" ) .(")。 !第 2 章1 引1 1 论 ! 本书 -./ " 表示以 * 为底的 " 的对数。 ! 因为当 !)! 时有 ! " ! "#$ " ! "#%! ,从而有 ! " ! "#$ # $(!)。 " 因为当 !)!" 时有 #!# " !!! % !" " &!# ,从而有 #!# " !!! % !" # $(!# )。 # 因为对所有 !)! 有 !# "!& ,从而有 !# # $(!& )。 $ 作为一个反例 !& * $(!# )。因为若不然,则存在正的常数 & 和自然数 !" ,使得当 !)!" 有 !& "&!# ,即 !"&。显然,当取 ! # ’(){!" ,& 」" !}时这个不等式不成立,所以 !& * $(!# )。 按照符号 $ 的定义,容易证明它有如下运算规则: % $(’)" $(()# $(’()(’,())。 ! $(’)" $(()# $(’ " ()。 " $(’)$(()# $(’()。 # 如果 ((!)# $(’(!)),则 $(’)" $(()# $(’)。 $ $(&’(!))# $(’(!)),其中 & 是一个正的常数。 & ’ # $(’)。 规则%的证明:设 )(!)# $(’)。根据符号 $ 的定义,存在正常数 &! 和自然数 !! ,使得对所 有的 ! ) !! ,有 )(!)" &! ’(!)。类似地,设 *(!)# $((),则存在正的常数 &# 和自然数 !# ,使 得对所有的 !)!# 有 *(!)" &# ((!)。 令 && # ’(){&! ,&# },!& # ’(){!! ,!# },+(!)# ’(){’,(},则对所有的 !)!& ,有 )(!)" &! ’(!)" &! +(!)" && +(!) 类似地,有 *(!)" &# ’(!)" &# +(!)" && +(!) 因而 $(’)" $(()# )(!)" *(!)" && +(!)" && +(!) # #&& +(!)# $(+)# $(’()(’,()) * * 其余规则的证明类似,可作为读者的练习。 应该指出,根据符号 $ 的定义,用它评估算法的复杂性,得到的只是当规模充分大时的一个 上界。这个上界的阶越低则评估就越精确,结果就越有价值。 与渐近复杂性有关的另一记号是 !,其定义如下:如果存在正的常数 & 和自然数 !" ,使得当 !)!" 时有 ’(!))&((!),则称函数 ’(!)当 ! 充分大时下有界,且 ((!)是它的一个下界,记为 ’(!)# !(((!))。这时还说 ’(!)的阶不低于 ((!)的阶。 用 ! 评估算法的复杂性,得到的只是该复杂性的一个下界。这个下界的阶越高,则评估就 越精确,结果就越有价值。这里的 ! 只对问题的一个算法而言。如果它是对一个问题的所有算 法或某类算法而言,即对于一个问题和任意给定的充分大的规模 !,下界在该问题的所有算法或 某类算法的复杂性中取值,那么它将更有意义。这时得到的相应下界,称之为问题的下界或某类 算法的下界。它常常与符号 $ 配合以证明某问题的一个特定算法是该问题的最优算法或该问 题的某算法类中的最优算法。 明白了符号 $ 和 ! 之后,符号 " 也随之明确。定义 ’( !)+ "( (( !))当且仅当 ’( !)+ $(((!))且 ’(!)+ !(((!))。这时,说 ’(!)与 ((!)同阶。 最后,如果对于任意给定的 # , ",都存在正整数 !" ,使得当 !)!" 时有 ’(!), ((!)- # ,则称 ! 数据结构与算法 函数 !(")当 " 充分大时的阶比 #(")低,记为 !(")$ %(#("))。 例如:!""#$" & % $ %(&"’ & !""#$" & %)。 () ’* 算法的表达与数据表示 !" #" !$ 问题求解 用计算机解决一个稍复杂的实际问题,大体都要经历如下的步骤: ! 将实际问题数学化,即把实际问题抽象为一个带有一般性的数学问题。这一步要引入一 些数学概念,精确地阐述数学问题,弄清问题的已知条件和所要求的结果,以及在已知条件和所 要求的结果之间存在着的隐式或显式的联系。 " 对于确定的数学问题,设计求其解的方法,即所谓的算法设计。这一步要建立问题的求 解模型,即确定问题的数据模型并在此模型上定义一组运算,然后借助于对这组运算的执行和控 制,从已知数据出发导向所要求的结果,形成算法并用自然语言来表述。这种语言不是程序设计 语言,不能被计算机所接受。 # 用计算机上的一种程序设计语言来表达已设计好的算法。换句话说,将非形式自然语言 表达的算法转变为用一种程序设计语言表达的算法。这一步称为程序设计或程序编制。 $ 在计算机上编辑、调试和测试编制好的程序,直到输出所要求的结果。 上述问题求解的过程中,求解问题的算法及其实现是核心内容。本章着重考虑第"步,而且 把注意力集中在算法表达的抽象机制上,目的是引入一个重要的概念,即抽象数据类型的概念, 同时为大型程序设计提供一种相应的自顶向下逐步求精的模块化方法,即运用抽象数据类型来 描述程序的方法。 !" #" #$ 表达算法的抽象机制 算法是一个运算序列。这个运算序列中的所有运算定义在一类特定的数学模型上,并以解 决一类特定问题为目标。这个运算序列应该具备下列 ! 个特征: ! 有限性,即序列的项数有限,且每一项运算都在有限时间内完成。 " 确定性,即序列的每一项运算都有明确的定义,无歧义。 # 可以没有输入运算项,但一定有输出运算项。 $ 可行性,即对于任意给定的合法的输入都能得到相应的正确输出。 这些特征可以用来判别一个确定的运算序列是否称得上是一个算法。 算法的程序表达,归根到底是算法要素的程序表达,因为一旦算法的每一项要素都用程序清 楚地表达,整个算法的程序表达也就不成问题。 很明显,算法有如下 & 要素: ! 作为运算序列中各种运算的运算对象和运算结果的数据。 " 运算序列中的各种运算。 !第 ( 章* 引* * 论 ! 运算序列中的控制转移。 这 ! 要素依序分别简称为数据、运算和控制。 由于算法层出不穷,变化万千,其中的运算所作用的对象数据和所得到的结果数据名目繁 多,不胜枚举。最简单最基本的有布尔值数据、字符数据、整数和实数数据等;稍复杂的有向量、 矩阵、记录等数据;更复杂的有集合、树和图,还有声音、图形、图像等数据。 同样由于算法层出不穷,变化万千,其中的运算的种类五花八门、多姿多彩。最基本和最初 等的有赋值运算、算术运算、逻辑运算和关系运算等;稍复杂的有算术表达式和逻辑表达式等;更 复杂的有函数值计算、向量运算、矩阵运算、集合运算,以及表、栈、队列、树和图的运算等;此外, 还可能有以上列举的运算的复合和嵌套。 控制转移相对单纯。在串行计算中,它只有顺序、分支、循环、递归和无条件转移等几种。 最早的程序设计语言是机器语言,即具体的计算机上的一个指令集。当时,要在计算机上运 行的所有算法都必须直接用机器语言来表达,计算机才能接受。算法的运算序列包括运算对象 和运算结果都必须转换为指令序列。其中的每一条指令都以编码(指令码和地址码)的形式出 现。这与用高级程序设计语言表达的算法相差甚远。对于没受过程序设计专门训练的人来说, 程序可读性极差。用机器语言表达算法的运算、数据和控制十分繁杂琐碎,因为机器语言所提供 的指令太初等、原始。机器语言只接受算术运算、按位逻辑运算和数的大小比较运算等。对于稍 复杂的运算,都必须分解到最初等的运算才能用相应的指令替代之。机器语言能直接表达的数 据只有最原始位、字节和字 ! 种。算法中即使是最简单的数据如布尔值、字符、整数和实数,也必 须映射到位、字节和字中,还要分配它们的存储。对于算法中有结构的数据的表达则要麻烦得 多。机器语言所提供的控制转移指令也只有无条件转移、条件转移、进入子程序和从子程序返回 等最基本的几种。用它们来构造循环、形成分支、调用函数都得事先做许多准备,还需要许多经 验和技巧。 直接用机器语言表达算法有许多缺点: " 大量繁杂琐碎的细节牵制着程序员,使他们不可能有更多的时间和精力去从事创造性的 劳动,执行对他们来说是更为重要的任务,如确保程序的正确性、高效性。 # 程序员既要驾驭程序设计的全局又要深入每一个局部直到实现的细节,即使智力超群的 程序员也常常会顾此失彼,屡出差错,因而所编出的程序可靠性差,且开发周期长。 ! 由于用机器语言进行程序设计的思维和表达方式与人们的习惯大相径庭,只有经过较长 时间职业训练的程序员才能胜任,使得程序设计曲高和寡。 $ 它的书面形式全是“密码”,可读性差,不便于交流与合作。 % 它严重地依赖于具体的计算机,可移植性和可重用性差。 克服上述缺点的出路在于程序设计语言的抽象,让它尽可能地接近于算法语言。为此,人们 首先注意到的是可读性和可移植性,因为它们相对地容易通过抽象而得到改善。 汇编语言实现了对机器语言的抽象,它将机器语言的每一条指令符号化:指令码代之以记忆 符号,地址码代之以符号地址,使得其含义显现在符号上而不再隐藏在编码中。另一方面,汇编 语言摆脱了具体计算机的限制,可在具有不同指令集的计算机上运行,只要该计算机配备了汇编 语言的一个汇编程序。这无疑是机器语言朝算法语言靠拢迈出的一步。但是,它离算法语言还 太远,以致程序员还不能从分解算法的数据、运算和控制,直至细化到汇编可直接表达的指令等 ! 数据结构与算法 繁杂琐碎的事务中解脱出来。 高级程序设计语言的出现使算法的程序表达产生了一次飞跃。诚然,算法最终要表达为具 体计算机上的机器语言才能在该计算机上运行,得到所需要的结果。但汇编语言的实践启发人 们,表达成机器语言不必一步到位,可以分两步走或者可以筑桥过河。即先表达成一种中间语 言,然后转成机器语言。汇编语言作为一种中间语言,并没有获得很大成功。原因是它离算法语 言还太远。这便指引人们去设计一种尽量接受算法语言的规范语言,即所谓的高级程序设计语 言。让程序员可以方便地用它表达算法,然后借助于规范的高级语言到规范的机器语言的“翻 译”,最终将算法表达为机器语言。而且,由于高级语言和机器语言都具有规范性,这里的“翻 译”完全可以机械化地由计算机来完成,就像汇编语言被翻译成机器语言一样,只要计算机配备 一个编译程序。上述两步,前一步由程序员去完成,后一步可以由编译程序去完成。在规定清楚 它们各自该做什么之后,这两步是完全独立的。它们各自该如何做则互不相干。前一步要做的 只是用高级语言正确地表达给定的算法,产生一个高级语言程序,后一步要做的只是将第一步得 到的高级语言程序翻译成机器语言程序。至于程序员如何用高级语言表达算法和编译程序如何 将高级语言表达的算法翻译成机器语言表达的算法,显然毫不相干。处理从算法语言最终表达 成机器语言这一复杂过程的上述思想方法就是一种抽象。汇编语言和高级语言的出现都是这种 抽象的范例。与汇编语言相比,高级语言的巨大成功在于它在数据、运算和控制 ! 方面的表达中 引入许多使之十分接近算法语言的概念和工具,大大地提高抽象地表达算法的能力。在运算方 面,高级语言除允许原封不动地运用算法语言的算术运算、逻辑运算、关系运算、算术表达式、逻 辑表达式外,还引入强有力的函数等工具,并让用户自定义。这一工具的重要性不仅在于它精简 了重复的程序文本段,而且在于它反映出程序的二级抽象。在函数调用级,人们只关心它能做什 么,不必关心它如何做。只是到定义函数时,人们才给出如何做的细节。用过高级语言的读者都 知道,一旦函数的名称、参数和功能被规定清楚,在程序中调用它们便与在程序的头部说明它们 完全分开。可以修改一个函数甚至更换函数体而不影响调用该函数。如果把函数名看成是运算 名,把参数看成是运算的对象或运算的结果,那么,函数调用和初等运算的引用就完全一样。利 用函数以及函数的复合或嵌套可以很自然地表达算法语言中任何复杂的运算。 在数据表示方面,高级语言引入了数据类型的概念,即把所有的数据加以分类。每一个数据 (包括表达式)或每一个数据变量都属于其中确定的一类。这一类数据称为一个数据类型。数 据类型是数据或数据变量类属的说明,它指示该数据或数据变量可能取的值的全体。对于无结 构的数据,高级语言除提供标准的基本数据类型外,还提供用户可自定义的枚举类型、子界类型 和指针类型等。这些类型的使用方式都顺应人们在算法语言中使用的习惯。对于有结构的数 据,高级语言提供了数组、记录、集合和文件等标准的结构数据类型。其中,数组是科学计算中的 向量、矩阵的抽象;记录是商业和管理中的记录的抽象;集合是数学中小集合的势集的抽象;文件 是诸如磁盘等外存储数据的抽象。人们可以利用所供的基本数据类型,按数组、记录、集合和文 件的构造规则构造有结构的数据。此外,还允许用户利用标准的结构数据类型,通过复合或嵌套 构造更复杂更高层的结构数据。这使得高级语言中的数据类型呈明显的分层。高级语言中数据 类型的分层是没有穷尽的,因而用它们可以表达算法语言中任何复杂层次的数据。 在控制方面,高级语言通常提供表达算法控制转移的如下方式: ! 缺省的顺序控制。 !第 " 章# 引# # 论 ! 条件(分支)控制。 " 选择(情况)控制。 # 循环控制。 $ 函数调用,包括递归函数调用。 % 无条件转移。 以上算法控制转移表达方式不仅覆盖了算法语言中所有控制表达的要求,而且不再像机器 语言或汇编语言那样原始、繁琐、隐晦,而是如上面所看到的,与自然语言的表达相差无几。 高级程序设计语言是对机器语言的进一步抽象,它所带来的主要好处是: & 高级语言更接近算法语言,易学、易掌握,一般工程技术人员只需要几周时间的培训就可 以胜任程序员的工作。 ! 高级语言为程序员提供了结构化程序设计的环境和工具,使得设计出来的程序可读性 好,可维护性强,可靠性高。 " 高级语言不依赖于机器语言,与具体的计算机硬件关系不大,因而所写出来的程序可移 植性好、重用率高。 # 由于把繁杂琐碎的事务交给了编译程序去做,所以自动化程度高,开发周期短,且程序员 得到解脱,可以集中时间和精力去从事对于他们来说更为重要的创造性劳动,提高程序的质量。 !" #$ 抽象数据类型 !" #" !$ 抽象数据类型的基本概念 与机器语言和汇编语言相比,高级语言的出现大大地简便了程序设计。但算法从非形式的 自然语言表达形式转换为形式化的高级语言表达,仍然是一个复杂的过程,仍然要做很多繁杂琐 碎的事情,因而仍然需要进一步抽象。 对于一个明确的数学问题,设计它的算法,总是先选用该问题的一个数据模型。接着,弄清 该问题所选用的数据模型在已知条件下的初始状态和要求的结果状态,以及这两个状态之间的 隐含关系。然后探索从数据模型的已知初始状态出发到达要求的结果状态所必需的运算步骤。 把这些运算步骤记录下来,就是求解该问题的算法。 按照自顶向下逐步求精的原则,在探索运算步骤时,首先应该考虑算法顶层的运算步骤,然 后再考虑底层的运算步骤。所谓顶层的运算步骤是指定义在数据模型级上的运算步骤,或称宏 观步骤。它们组成算法的主干部分。这部分算法通常用非形式的自然语言表达。其中涉及的数 据是数据模型中的一个变量,暂时不关心它的数据结构;涉及的运算以数据模型中的数据变量作 为运算对象,或作为运算结果,或二者兼而为之,简称为定义在数据模型上的运算。由于暂时不 关心变量的数据结构,这些运算都带有抽象性质,不含运算的细节。所谓底层的运算步骤是指顶 层抽象的运算的具体实现。它们依赖于数据模型的结构,依赖于数据模型结构的具体表示。因 此,底层的运算步骤包括两部分:一是数据模型的具体表示;二是定义在该数据模型上的运算的 具体实现。可以把它们理解为微观运算。于是,底层运算是顶层运算的细化;底层运算为顶层运 ! 数据结构与算法 算服务。为了将顶层算法与底层算法隔开,使二者在设计时不会互相牵制、互相影响,必须对二 者的接口进行一次抽象。让底层只通过这个接口为顶层服务,顶层也只通过这个接口调用底层 的运算。这个接口就是抽象数据类型,其英文术语是 !"#$%&’$ (&$& )*+,#,简记 !()。 抽象数据类型是算法设计和程序设计中的重要概念。严格地说,它是算法的一个数据模型 连同定义在该模型上并作为该算法构件的一组运算。这个概念明确地把数据模型与该模型上的 运算紧密地联系起来。事实正是如此。一方面,如前面指出过的,数据模型上的运算依赖于数据 模型的具体表示,因为数据模型上的运算以数据模型中的数据变量作为运算对象,或作为运算结 果,或二者兼而为之。另一方面,有了数据模型的具体表示,有了数据模型上运算的具体实现,运 算的效率随之确定。于是,就有这样一个问题:如何选择数据模型的具体表示使该模型上的各种 运算的效率都尽可能地高?很明显,对于不同的运算组,为使该运算组中所有运算的效率都尽可 能地高,其相应的数据模型的具体表示将是不同的。在这个意义下,数据模型的具体表示又反过 来依赖于数据模型上定义的那些运算。特别是,当不同运算的效率互相制约时,还必须事先将所 有的运算相应的使用频度排序,让所选择的数据模型的具体表示优先保证使用频度较高的运算 有较高的效率。数据模型与定义在该模型上的运算之间存在着的这种密不可分的联系是抽象数 据类型的概念产生的背景和依据。 应该指出,抽象数据类型的概念并不是全新的概念。它实际上是基本数据类型概念的引申 和发展。用过高级语言进行算法设计和程序设计的人都知道,基本数据类型已隐含着数据模型 和定义在该模型上的运算的统一。事实上,大家都清楚,基本数据类型中的逻辑类型就是逻辑值 数据模型与 - 种逻辑运算(或、与、非)的统一体;整数类型就是整数值数据模型与 . 种算术运算 (加、减、乘、除)的统一体;实型和字符型等也类同。每一种基本类型都连带着一组基本运算。 只是由于这些基本数据类型中的数据模型的具体表示、基本运算和具体实现都很规范,都可以通 过系统内置而隐蔽起来,使人们看不到它们的封装。许多人习惯于在算法与程序设计中用基本 数据类型名和相关的运算名,而不问其究竟。所以没有意识到抽象数据类型的概念已经孕育在 基本数据类型的概念之中。 回到定义算法的顶层和底层的接口,即定义抽象数据类型。根据抽象数据类型的概念,对抽 象数据类型进行定义就是约定抽象数据类型的名字,同时,约定在该类型上定义的一组运算的各 个运算的名字,明确各个运算分别要有多少个参数,这些参数的含义和顺序,以及运算的功能。 一旦定义清楚,在算法的顶层就可以像引用基本数据类型那样,十分简便地引用抽象数据类型; 同时,算法的底层就有了设计的依据和目标。顶层和底层都与抽象数据类型的定义打交道。顶 层运算与底层运算没有直接的联系。因此,只要严格按照定义办,顶层算法的设计和底层算法的 设计就可以互相独立,互不影响,实现对它们的隔离,达到抽象的目的。 在定义了抽象数据类型之后,算法底层的设计任务就可以明确为: ! 对于每一个抽象数据类型赋予其具体的构造数据类型,或者说,对于每一个抽象数据类 型名赋予其具体的数据结构。 " 对于每一个抽象类型上所定义的每一个运算名赋予其具体的运算内容,或者说,赋予其 具体的函数。 因此,落实下来,算法底层的设计就是数据结构的设计和函数的设计。用高级语言表达,就 是构造数据类型的定义和函数的说明。 !第 / 章0 引0 0 论 不言而喻,由于实际问题千差万别,数据模型千姿百态,问题求解的算法千变万化,抽象数据 类型的设计和实现不可能像基本数据类型那样规范。它要求算法设计和程序设计人员因时因地 制宜,自行筹划,目标是使抽象数据类型对外的整体效率尽可能地高。本书在介绍各种抽象数据 类型时会给出一些范例,供设计和实现时参考选用。 !" #" $% 使用抽象数据类型的好处 使用抽象数据类型将给算法和程序设计带来很多好处。其中主要有: ! 算法顶层的设计与底层的实现分离,使得在进行顶层设计时不考虑它所用到的数据和运 算将分别如何表示和实现;反过来,在进行数据表示和底层运算实现时,只要定义清楚抽象数据 类型而不必考虑它将在什么场合被引用。这样做,算法和程序设计的复杂性降低了,条理性增强 了。既有助于迅速开发出程序的原型,又有助于在开发过程中少出差错,保证编出的程序有较高 的可靠性。 " 算法设计与数据结构设计隔开,允许数据结构自由选择,从中比较,优化算法和程序运行 的效率。 # 数据模型和该模型上的运算统一在抽象数据类型中,反映了它们之间内在的互相依赖和 互相制约的关系,便于空间和时间耗费的折中,灵活地满足用户的要求。 $ 由于顶层设计和底层实现的局部化,在设计中出现的差错也是局部的,因而容易查找也 容易纠正。在设计中常常要做的增、删、改也都是局部的,因而也都很容易进行。因此,可以肯 定,用抽象数据类型表述的程序具有很好的可维护性。 % 编出来的程序自然地呈现模块化,而且抽象的数据类型的表示和实现都可以封装起来, 便于移植和重用。 & 为自顶向下逐步求精和模块化提供一种有效的途径和工具。 ’ 编出来的程序结构清晰,层次分明,便于程序正确性的证明和复杂性的分析。 !" #$ 数据结构、数据类型和抽象数据类型 数据结构、数据类型和抽象数据类型,这 % 个术语在字面上既不同又相近,反映出它们在含 义上既有区别又有联系。 数据结构是在整个计算机科学与技术领域中广泛使用的术语。它用来反映数据的内部构 成,即数据由哪些成分数据构成,以什么方式构成,呈什么结构。数据结构有逻辑上的数据结构 和物理上的数据结构之分。逻辑上的数据结构反映成分数据之间的逻辑关系;物理上的数据结 构反映成分数据在计算机内的存储安排。数据结构是数据存在的形式。 数据是按照数据结构分类的,具有相同数据结构的数据属同一类。同一类数据的全体称为 一个数据类型。在高级程序设计语言中,数据类型用来说明数据在数据分类中的归属。它是数 据的一种属性。这个属性限定了该数据的变化范围。为了解题的需要,根据数据结构的种类,高 级语言定义了一系列的数据类型。不同的高级语言所定义的数据类型不尽相同。简单数据类型 !" 数据结构与算法 对应于简单的数据结构;构造数据类型对应于复杂的数据结构;在复杂的数据结构里,允许成分 数据本身具有复杂的数据结构,因此,构造数据类型允许复合嵌套;指针类型对应于数据结构中 成分数据之间的关系,表面上属简单数据类型,实际上都指向复杂的成分数据即构造数据类型中 的数据,因此单独划出一类。 由于数据类型是按照数据结构划分的,因此,一类数据结构对应着一种数据类型。一个数据 变量,在高级语言中的类型说明必须是该变量所具有的数据结构所对应的数据类型。 最常用的数据结构是数组结构和记录结构。数组结构的特点是: ! 成分数据的个数固定,它们之间的逻辑关系由成分数据的序号(数组的下标)来体现。这 些成分数据按照序号的先后顺序一个挨一个地排列起来。 " 每一个成分数据具有相同的结构(可以是简单结构,也可以是复杂结构),因而属于同一 数据类型(相应地是简单数据类型或构造数据类型)。这种同一的数据类型称为基类型。 # 所有成分数据被依序安排在一片连续的存储单元中。概括起来,数组结构是一个线性 的、均匀的、可随机访问其成分数据的结构。由于这种结构有这些良好的特性,所以最常被人们 所采用。 记录结构是另一种常用的数据结构。它的特点是: ! 与数组结构一样,成分数据的个数固定。但成分数据之间没有自然序,它们处于平等地 位。每一个成分数据被称为一个域并赋予域名。不同的域有不同的域名。 " 不同的域允许有不同的结构,因而允许属于不同的数据类型。 # 与数组结构一样,可以随机访问其成分数据,但访问的途径是靠域名。在高级语言中记 录结构对应的数据类型是记录结构类型。 抽象数据类型的含义在上一段已作了专门叙述。它可理解为数据类型的进一步抽象。即把 数据类型和数据类型上的运算绑定并封装。引入抽象数据类型的目的是把数据类型的表示和数 据类型上运算的实现与这些数据类型和运算在程序中的引用隔开,使它们相互独立。对于抽象 数据类型的描述,除了必须描述它的数据结构外,还必须描述定义在它上面的运算。抽象数据类 型上定义的运算以该抽象数据类型的数据所应具有的数据结构为基础。 !" #$ 用 % 语言描述数据结构与算法 描述数据结构与算法可以有多种方式,如自然语言方式、表格方式等。在本书中,采用 % 语 言来描述数据结构与算法。% 语言的优点是类型丰富、语句精炼,使用灵活。用 % 语言来描述算 法可使整个算法结构紧凑,可读性强。在本书中,有时为了更好地阐明算法的思路,还采用 % 语 言与自然语言相结合的方式来描述算法。本节对 % 语言的若干重要特性作简要概述。 !" #" !$ 变量和指针 (!)变量 变量是程序设计语言对存储单元的抽象,它具有以下属性: !!第 ! 章$ 引$ $ 论 ! 变量名(!"#$):变量名是用于标识变量的符号。 " 地址("%%&$’’):地址是变量所占据的存储单元的地址。变量的地址属性也称为左值。 # 大小(’()$):变量的大小指该变量所占据的存储空间的数量(以字节数来衡量)。 $ 类型(*+,$):变量的类型指变量所取的值域以及对变量所能执行的运算集。 % 值(-"./$):变量的值是指变量所占据的存储单元中的内容。这些内容的意义由变量的 类型所决定。变量的值属性也称为右值。 & 生命期(.(0$*(#$):变量的生命期是指在执行程序期间变量存在的时段。 ’ 作用域(’12,$):变量的作用域是指在程序中变量被引用的语句范围。 (3)指针变量 4 语言中的指针变量是一个% ! 类型的变量。其中 ! 为任一已定义的类型。指针变量用于 存放对象的存储地址,例如: (!* 5,!,!,; ! !6; , !7!; 5 !!,; 其中," 是一个指向 (!* 的指针。通过间接引用指针,存取指针所指向的变量。 !" #" $% 函数与参数传递 (8)函数 4 语言中函数定义包括 9 个部分:函数名、形式参数表、返回类型和函数体。函数的使用者 通过函数名来调用该函数。调用函数时,将实际参数传递给形式参数作为函数的输入。函数体 中的处理程序实现该函数的功能。最后将得到的结果作为返回值输出。例如,下面的函数 #": 是一个简单函数的例子。 (!* #":((!* :,(!* +) { &$*/&! : "+?::+; } 其中,#": 是函数名;函数后圆括号中的 (!* : 和 (!* + 是形式参数;函数前面的 (!* 是返回类型;花 括号内是函数体,它实现函数的具体功能。 4 语言中函数一般都有一个返回值。函数的返回值表示函数的计算结果或函数执行状态。 如果所定义的函数不需要返回值,可使用 -2(% 来表示它的返回类型。函数的返回值通过函数体 中的 &$*/&! 语句返回。&$*/&! 语句的作用是返回一个与返回类型相同类型的值,并中止函数的执 行。 !" 数据结构与算法 (!)参数传递 在 " 语言中调用函数时传递给形参表的实参必须与形参在类型、个数、顺序上保持一致。 参数传递有两种方式。一种是按值传递方式。在这种参数传递方式下,把实参的值传递给函数 局部工作区相应的副本中。函数使用副本执行必要的计算。因此函数实际修改的是副本的值, 实参的值不变。 参数传递的另一种方式是按地址传递参数。在这种参数传递方式下,需将形参声明为指针 类型,即在参数名前加上符号“!”。当一个实参与一个指针类型结合时,被传递的不是实参的 值,而是实参的地址。函数通过地址存取被引用的实参。执行函数调用后,实参的值将发生改 变。例如 #$%& ’()*(%+, !-,%+, !.) { / / %+, ,01* !!-; / / !- !!.; / / !. !,01*; } 函数调用 ’()*(2-,2.)交换变量 - 和 . 的值。 在 " 语言中数组参数的传递属特殊情形。数组作为形参可按值传递方式声明,但实际传递 的是数组第一个元素的地址。因此在函数体内对于形参数组所作的任何改变都会在实参数组中 反映出来。 !" #" $% 结构 (3)定义结构 " 语言的结构(’,456,540)为自定义数据类型提供了灵活方便的方法,可用于实现抽象数据类 型的思想,将说明与实现分离。 结构由结构名和结构的数据成员组成。说明结构的标准形式是: ’,456, 结构名 { / / 数据成员列表; }; (!)指向结构的指针 指向结构的指针值是相应的结构变量所占据的内存空间的首地址。例如,如果已经定义了 一个结构 ’,,则语句 ’,456, ’, !*;定义一个指向结构 ’, 的指针。 (7)用 ,.*0&08 定义新数据类型 !"第 3 章/ 引/ / 论 关键字 !"#$%$& 常与结构一起用于定义新数据类型。 例如,下面是用 !"#$%$& 和结构定义矩形数据类型 ’$(!)*+,$ 的例子。 !"#$%$& -!./(! .$(*0%$ !’$(!)*+,$; !"#$%$& -!./(! .$(*0%$ 1 1 1 1 { 1 1 1 1 1 1 2*! 3,",4,5; 1 1 1 1 1 1 #!(3,")是矩形左下角点的坐标; 1 1 1 1 1 1 1 4 是矩形的高;5 是矩形的宽。!# 1 1 1 1 }’$(*0%$; (6)访问结构变量的数据成员 对于结构类型的变量用圆点运算符“7 ”访问结构变量的数据成员。定义为指向结构的指针 类型的变量用箭头运算符“ $"”访问结构变量的数据成员。例如: ’$(*0%$ ..; ’$(!)*+,$ ’; ’ !8..; ..7 3 !9;..7 " !9;..7 4 !:;..7 5 !;; #.2*!&(%3 !< % " !< % &*%,’ $"3,’ $""); #.2*!&(%=$2+4! !< % >2%!4 !< % &*%,..7 4,..7 5); 其中,.. 是一个结构类型的变量,’ 是一个指向结构的指针类型的变量。 (?)新数据类型变量初始化 使用自定义数据类型变量前通常需要初始化操作。下面的函数用于说明一个 ’$(!)*+,$ 型 变量并对其初始化。 ’$(!)*+,$ ’$(@*2!() { 1 ’$(!)*+,$ ’ !A),,0((-2B$0& !’); 1 ’ $"3 !C;’ $"" !C;’ $"4 !C;’ $"5 !C; 1 .$!/.* ’; } !" #" $% 动态存储分配 (9)动态存储分配函数 A),,0(()和 &.$$() !" 数据结构与算法 ! 语言的标准函数 "#$$%&()和 ’())()可用于动态存储分配。例如: &*#( !+,(; #!为字符串分配内存!# -’((+,( !(&*#( !)"#$$%&(./))! !/) { 0 0 1(-2,’(% 内存不足 &2%); 0 0 )3-,(.);#!退出 !# } +,(&14(+,(,%5)$$%%); #!显示字符串!# 1(-2,’(%6,(-27 -+ 8 +&2%,+,(); #!释放内存!# ’())(+,(); (9)动态数组 为了在运行时创建一个大小可动态变化的一维浮点数组 3,可先将 3 声明为一个 ’$%#, 类型 的指针。然后用函数 "#$$%&()为数组动态地分配存储空间。例如 ’$%#, !3 !"#$$%&(2!+-:)%’(’$%#,)); 创建一个大小为 2 的一维浮点数组。然后可用 3[/],3[.],⋯ ,3[2 ; .]访问每个数组元素。 (<)二维数组 ! 语言提供了多种声明二维数组的机制。在许多情况下,当形式参数是一个二维数组时,必 须指定其第二维的大小。例如,#[][./]是一个合法的形式参数,而 #[][]则不是。为了克服这 种限制,可以使用动态分配的二维数组。例如,下面的函数创建一个 -2, 类型的动态工作数组,这 个数组有 ( 行和 & 列。 -2, !!"#$$%&9=(-2, (,-2, &) {-2, -; 0 -2, !!, !"#$$%&(( !+-:)%’(-2,!)); 0 ’%((- !/;- ’(;- ( () 0 0 ,[-]!"#$$%&(& !+-:)%’(-2,)); 0 (),>(2 ,; } 其他类型的二维动态数组可用类似方法创建。 !"第 . 章0 引0 0 论 本 章 小 结 本章介绍了算法的基本概念、表达算法的抽象机制以及算法计算复杂性的概念和分析方法。 简要阐述了数据类型、数据结构和抽象数据类型的基本概念以及这三个重要概念的区别和内在 联系。最后简要概述 ! 语言的若干重要特性和采用 ! 语言与自然语言相结合的方式描述算法 的方法。本章内容是后续各章叙述算法和描述数据结构的基础和准备。 习" " 题 !" !# 试列举在 ! 语言编程环境中,下列基本数据类型变量能表示的最大数和最小数。 (#)$%&" (’)()%* $%&" (+),-).& $%&" (/)0()1&" (2)3)45(6 !" $# 什么是抽象数据类型?试述抽象数据类型与数据结构的区别和联系。 !" %# 试用 ! 语言的结构类型定义表示复数的抽象数据类型。 (#)在复数内部用浮点数定义其实部和虚部。 (’)设计实现复数的 7 、8 、!、9 等运算的函数。 !" &# 求下列函数尽可能简单的渐近表达式: (#)+!’ " #:! (’)!’ # #: " ’! (+)’# " # # ! (/)()* !+ (2)#:()*+! !" ’# 试述 $(#)和 $(’)的区别。 !" (# 画出下列表达式的函数图像,并说明各表达式当 ! 在什么范围内取值时效率最高。 (#)/!’ " (’)()*!" (+)+! " (/)’:!" (2)’ " (;)!’ 9 + !" )# 按照渐近阶从低到高的顺序排列以下表达式:/!’ 、()*!、+! 、’:!、’、!’ 9 + 。问 !!应该排在哪一位? !" *# (#)假设某算法在输入规模为 ! 时的计算时间为 %(!)& + ’ ’! 。在某台计算机上实现并完成该算 法的时间(单位为 ,)为 (。现有另一台计算机,其运行速度为第一台的 ;/ 倍,那么在这台新机器上用同一算法 在时间 ( 内能解输入规模为多大的问题? (’)若上述算法的计算时间改进为 %(!)& !’ ,其余条件不变,则在新机器上用时间 ( 能解输入规模为多大 的问题? (+)若上述算法的计算时间进一步改进为 %(!)& < ,其余条件不变,那么在新机器上用时间 ( 能解输入规 模为多大的问题? !" +# 硬件厂商 =>? 公司宣称他们最新研制的微处理器运行速度为其竞争对手 @A! 公司同类产品的 #:: 倍。对于计算复杂性分别为 !、!’ 、!+ 和 !!的各算法,若用 @A! 公司的计算机能在 #- 内能解输入规模为 ! 的问 题,那么用 =>? 公司的计算机在 #- 内分别能解输入规模为多大的问题? !" !,# 对于下列各组函数 )(!)和 *(!),确定 )(!)& $(*(!))或 )(!)& !(*(!))或 )(!)& "(*(!)),并 简述理由。 (#))(!)& ()*!’ ;*(!)& ()*! " 2 (’))(!)& ()*!’ ;*(!) !& ! !" 数据结构与算法 (!)!(")# ";$(")# "#$% " (&)!(")# ""#$" % ";$(")# "#$" (’)!(")# ();$(")# "#$() (*)!(")# "#$% ";$(")# "#$" (+)!(")# %" ;$(")# ())"% (,)!(")# %" ;$(")# !" !" !!# 证明 "!# -("" )。 !" !$# 证明:如果一个算法在平均情况下的计算时间复杂性为 !(!(")),则该算法在最坏情况下所需的计 算时间为 "(!("))。 !"第 ( 章. 引. . 论 书书书 第 ! 章" 表 学习目标 · 理解表是由同一类型的元素组成的有限序列的概念。 · 熟悉定义在抽象数据类型表上的基本运算。 · 掌握实现抽象数据类型的一般步骤。 · 掌握用数组实现表的步骤和方法。 · 掌握用指针实现表的步骤和方法。 · 掌握用间接寻址技术实现表的步骤和方法。 · 掌握用游标实现表的步骤和方法。 · 掌握单循环链表的实现方法和步骤。 · 掌握双链表的实现方法和步骤。 · 熟悉表的搜索游标的概念和实现方法。 !" #$ %&’ 表 表或称线性表是一种非常灵活的结构,可以根据需要改变表的长度,也可以在表中任何位置 对元素进行访问、插入或删除等操作。另外,还可以将多个表连接成一个表,或把一个表分拆成 多个表。表结构在信息检索、程序设计语言的编译等许多方面有广泛应用。 就数学模型而言,表是由 !(!!()个同一类型的元素()*)+),-)"(#),"(!),⋯ ,"(!)组成的 有限序列。其中,元素的个数 ! 定义为表的长度。当 ! 的值为 ( 时称为空表。当 !!# 时,称元 素 "(#)位于该表的第 # 个位置,或称 "(#)是表中第 # 个元素,# . #,!,⋯ ,!。根据各元素在表中 的不同位置可以定义它们在表中的前后次序。称元素 "(#)在元素 "(# / #)之前,或 "(#)是 "(# / #)的前驱(# . #,!,⋯ ,! 0 #)。同时,也称元素 "(# / #)在元素 "(#)之后,或 "(# / #)是 "(#) 的后继。 从表的定义可以看出它的逻辑特征是:对于非空的表,有且仅有一个开始元素 !(!),它没有 前驱,而有一个后继 !(");有且仅有一个结束元素 !("),它没有后继,而有一个前驱 !(" # !)。 其余的元素 !(#)(""#"" # !)都有一个前驱和一个后继。表元素之间的逻辑关系就是上述的 邻接关系。由于这种关系是线性的,所以表是一种线性结构,有时也称为线性表。 在上述数学模型上,还要定义一组关于表的运算,才能使这一数学模型成为一个抽象数据类 型 $%&’。下面给出一组典型的表运算。其数学模型是由类型为 $%&’(’)* 的元素组成的一个表。 用 $ 表示表中的一个元素,# 表示元素在表中的位置,其类型为 +,&%’%,-。在表的不同实现方式 下,+,&%’%,- 可能是不同的类型,例如整型或指针型等。为了便于叙述,非形式地将 +,&%’%,- 看作 是整数,并假设 # 所代表的位置上的元素是 !(#)。要注意的是,在具体实现表及其运算时,应区 分 # 和 # 所表示的位置,以及该位置上的元素的具体含义。 ! $%&’.*+’/($):测试表 % 是否为空。 " $%&’$)-0’1($):表 % 的长度。 # $%&’$,23’)(4,$):元素 $ 在表 % 中的位置。若 $ 在表中重复出现多次,则返回最前面的 $ 的位置。 $ $%&’5)’6%)7)(8,$):返回表 % 的位置 # 处的元素。表中没有位置 # 时,该运算无定义。 % $%&’(-&)6’(8,4,$):在表 % 的位置 # 之后插入元素 $,并将原来占据该位置的元素及其后 面的元素都向后推移一个位置。 例如,设表 % 为 !(!),!("),⋯ ,!("),那么在执行 $%&’(-&)6’(8,4,$)后,表 % 变为 !(!), !("),⋯ ,!(#),$,!(# 9 !),⋯ ,!(")。若表中没有位置 #,则该运算无定义。 & $%&’:);)’)(8,$):从表 % 中删除位置 # 处的元素,并返回被删除的元素。 例如,当表 % 为 !(!),!("),⋯ ,!(")时,执行 $%&’:);)’)(8,$)后,表 % 变为 !(!),!("),⋯ , !(# # !),!(# 9 !),⋯ ,!(")。返回的元素为 !(#)。当表中没有位置 # 时,该运算无定义。 ’ <6%-’$%&’($):将表 % 中所有元素按位置的先后次序打印输出。 在表的数学模型上,定义了上述运算后,就定义了抽象数据类型 $%&’。当然,也并非任何时 候都需要同时执行以上运算,在有些问题中只需要上述的一部分运算,因此也可以用一部分上述 运算来定义适合特殊目的的抽象数据类型。上述表的运算是一些最基本的运算,对于实际问题 中涉及的关于表的更复杂的运算,通常可以用这些基本运算的组合来实现。 "= "> 用数组实现表 将一个表存储到计算机中,可以采用许多不同的方法,其中既简单又自然的是顺序存储方 法,即将表中的元素逐个存放于数组的一些连续的存储单元中。在这种表示方式下,容易实现对 表的遍历。要在表的尾部插入一个新元素,也很容易。但是要在表的中间位置插入一个新元素, 就必须先将其后面的所有元素都后移一个单元,才能腾出新元素所需的位置。执行删除运算的 情形也是类似的。如果被删除的元素不是表中最后一个元素,则必须将后面的所有元素前移,以 填补由于删除所造成的空缺。 用数组实现表时,为了适应表元素类型的变化,将表类型 $%&’ 定义为一个结构。在该结构 !"第 " 章> 表 中,用 !"#$%$&’ 来表示用户指定的元素类型。其数据成员为 !,’()#"*& 和元素数组 $(+,&。用 ! 记 录表长。当表为空时,! 的值为 -。’()#"*& 表示表的最大长度。$(+,& 是记录表中元素的数组。 表中第 " 个元素(."""!)存储在数组的第 " / . 个单元中,如图 01 . 所示。 图 01 .2 用数组实现表 在这种情况下,位置变量的类型是整型,整数 " 表示数组的第 " / . 个单元,即表中第 " 个元 素的位置。 $34&5&6 #$789$ (,"#$ !!"#$; $34&5&6 #$789$ (,"#${ 2 2 2 ":$ :; 2 2 2 ":$ ’()#"*&; 2 2 2 !"#$%$&’ $(+,&;2 !!表元素数组 !! };,"#$; 在定义了一个实现抽象数据类型的结构后,需要定义实现该抽象数据类型上各运算的接口。 在 < 语言中,可以将这些运算的声明放在一个头文件中,而将运算的具体实现分离出来。抽象 数据类型的使用者可以不必关心运算的具体实现方法。 抽象数据类型 !"#$ 的 = 个基本运算和结构初始化运算的接口如下: !"#$ !"#$%:"$(":$ #"*&);2 2 2 2 2 2 2 2 2 !!表结构初始化 !! ":$ !"#$>’4$3(!"#$ !);2 2 2 2 2 2 2 2 2 !!测试表 ! 是否为空 !! ":$ !"#$!&:?$@(!"#$ !);2 2 2 2 2 2 2 2 !!表 ! 的长度 !! !"#$%$&’ !"#$A&$7"&B&(":$ C,!"#$ !);2 2 2 !!返回表 ! 的位置 C 处的元素 !! ":$ !"#$!D9($&(!"#$%$&’ ),!"#$ !);2 2 2 2 !!元素 ) 在表 ! 中的位置 !! BD"5 !"#$%:#&7$(":$ C,!"#$%$&’ ),!"#$ !);2 !!在表 ! 的位置 C 之后插入元素 ) !! !"#$%$&’ !"#$E&,&$&(":$ C,!"#$ !);2 2 2 2 !!从表 ! 中删除位置 C 处的元素 !! BD"5 F7":$!"#$(!"#$ !);2 2 2 2 2 2 2 2 2 !!按位置次序输出表 ! 中元素 !! 抽象数据类型 !"#$ 的上述接口对任何实现都通用。下面讨论用数组实现表时上述接口的具 体实现方法。 表结构初始化函数 !"#$%:"$(#"*&)分配大小为 #"*& 的空间给表数组 $(+,&,并返回初始化为空 的表。 !"#$ !"#$%:"$(":$ #"*&) { !" 数据结构与算法 ! "#$% " "&’(()*($#+,)- !"); ! " #$%’.(, "&’(()*($#+,!$#+,)-("#$%/%,&)); ! " #$&’0$#+, "$#+,; ! " #$1 "2; ! 3,%431 "; } 由于表结构中已记录了当前表的大小,因此 "#$%5&6%7(")和 "#$%",18%9(")均只需 !(:)计 算时间。 #1% "#$%5&6%7("#$% ") { ! 3,%431 " #$1 ""2; } #1% "#$%",18%9("#$% ") { ! 3,%431 " #$1; } 表运算 "#$%")*’%,(0,")和 "#$%;,%3#,<,(=,")也很容易实现。 #1% "#$%")*’%,("#$%/%,& 0,"#$% ") { ! #1% #; ! -)3(# "2;# %" #$1;# &&) ! ! #-(" #$%’.(,[#]""0)3,%431 &&#; ! 3,%431 2; } "#$%")*’%,(0,")返回元素 " 在表中的位置,当元素 " 不在表中时返回 2。该算法在数组 %’.(, 中从前向后通过比较来查找给定元素的位置,其基本运算是比较两个元素。若在表中位置 # 找 到给定元素,则需要进行 # 次比较,否则需要进行 $ 次比较,$ 为表的长度。因此在最坏情况下, 算法 "#$%")*’%,(0,")需要 !($)时间。 "#$%/%,& "#$%;,%3#,<,(#1% =,"#$% ") { ! #-(= %: ’’ = $" #$1)533)3(()4% )- .)41>$(); !"第 ? 章! 表 ! "#$%"& ’ #$$()*#[+ #,]; } ’-.$/#$"-#0#(+,’)返回表位置 ! 处的元素。表中没有位置 ! 时,给出错误信息。算法’-.$/#1 $"-#0# 显然只需 "(,)计算时间。 以下主要讨论表元素的插入和删除运算的实现。 表元素插入运算 ’-.$2&.#"$(+,3,’): 04-5 ’-.$2&.#"$(-&$ +,’-.$2$#6 3,’-.$ ’) { ! -&$ -; ! -7(+ %8 ’’ + $’ #$&)9""4"((4%$ 47 )4%&5.(); ! -7(’ #$& ""’ #$6(3.-:#)9""4"((4%$ 47 6#64";(); ! 74"(- "’ #$& #,;- $"+;- ##)’ #$$()*#[- &,]"’ #$$()*#[-]; ! ’ #$$()*#[+]"3; ! ’ #$& &&; } 算法 ’-.$2&.#"$(+,3,’)将表 # 位于 ! < ,,⋯ ,$ 处的元素分别移到位置 ! < =,⋯ ,$ < , 处,然 后将新元素 % 插入位置 ! < , 处。注意算法中元素后移的方向,必须从表中最后一个位置开始后 移,直至将位置 ! < , 处的元素后移为止。如果新元素的插入位置不合法,或表已经满了则给出 错误信息。 现在来分析算法的时间复杂性。这里问题的规模是表的长度 $。显然该算法的主要时间花 费在 74" 循环的元素后移上,该语句的执行次数为 $ & !。由此可看出,所需移动元素位置的次数 不仅依赖于表的长度,而且还与插入的位置 ! 有关。当 ! ’ $ 时,由于循环变量的终值大于初值, 元素后移语句将不执行,无须移动数组元素;若 ! > 8,则元素后移语句将循环执行 $ 次,需移动 表中所有元素。也就是说该算法在最好情况下需要 "(,)时间,在最坏情况下需要 "($)时间。 由于插入可能在表中任何位置上进行,因此,有必要分析算法的平均性能。设在长度为 $ 的表中 进行插入运算所需的元素移动次数的平均值为 (2?($)。由于在表中第 ! 个位置上插入元素 % 需要的移动数组元素 $ & ! 次,故 (2?($)’ # $ ! ’ 8 )!($ & !) 其中,)! 表示在表中第 ! 个位置上插入元素的概率。不失一般性,假设在表中任何合法位置(8" ! "$)上插入元素的机会是均等的,则 )8 ’ ), ’ ⋯ ’ )$ ’ , $ * ,。 因此,在等概率插入的情况下,有 (2?($)’ # $ ! ’ 8 )!($ & !)’ # $ ! ’ 8 $ & ! $ * , ’ $ + = !! 数据结构与算法 也就是说,用数组实现表时,在表中做插入运算,平均要移动表中一半的元素,因而算法所需的平 均时间仍为 !(")。 表元素删除运算 !"#$%&’&$&$((,!)实现方法如下: !"#$)$&* !"#$%&’&$&("+$ (,!"#$ !) { , "+$ ";!"#$)$&* -; , ".(( %/ ’’ ( $! #$+)01121((23$ 2. 423+5#(); , - "! #$$64’&[( #/]; , .21(" "(;" %! #$+;" &&)! #$$64’&[" #/]"! #$$64’&["]; , ! #$+ ##; , 1&$31+ -; } 算法 !"#$%&’&$&$((,!)通过将表 # 位于 $ 7 /,$ 7 8,⋯ ," 处的元素移到位置 $,$ 7 /,⋯ ," 9 / 来删除原来位置 $ 处的元素。该算法的时间分析与插入算法类似,元素的移动次数也是由表长 " 和位置 $ 决定的。若 $ % ",则由于循环变量的初值大于终值,前移语句将不执行,无须移动数 组元素;若 $ : /,则前移语句将循环执行 " 9 / 次,需要移动表中除删除元素外的所有元素。因 此,算法在最好情况下需要 !(/)时间,而在最坏情况下需要 !(")时间。 删除运算的平均性能分析与插入运算类似。设在长度为 " 的表中删除一个元素所需的平均 移动次数为 &%0(")。由于删除表中第 $ 个位置上的元素需要移动数组元素 " 9 $ 次,故有 &%0(")% # " $ % / ’$(" ( $) 其中,’$ 表示删除表中第 $ 个位置上元素的概率。在等概率删除的情况下,有 ’/ % ’8 % ⋯ % ’" % / " 由此可知 &%0(")% # " $ % / ’$(" ( $)% # " $ % / " ( $ " %(" ( /)) 8 也就是说,用数组实现表时,在表中做删除运算,平均要移动表中约一半的元素,因而删除运算所 需的平均时间为 !(")。 ;1"+$!"#$(!)运算实现方法如下: <2"5 ;1"+$!"#$(!"#$ !) { , "+$ "; , .21(" "=;" %! #$+;" &&), )$&*>?2@(! #$$64’&["]); } !"第 8 章, 表 算法 !"#$%&#’%(&)将表中每个元素用表元素输出函数 (%)*+,-. 输出。算法显然需要 !(") 时间。 /0 12 用指针实现表 用数组实现表时,利用了数组单元在物理位置上的邻接关系表示表元素之间的逻辑关系。 这一特点使得用数组实现表有如下优缺点。 其优点是: ! 无须为表示表元素之间的逻辑关系增加额外的存储空间。 " 可以方便地随机存取表中任一位置的元素。 其缺点是: ! 插入和删除运算不方便,除表尾位置外,在表的其他位置上进行插入或删除操作都必须 移动大量元素,效率较低。 " 由于数组要求占用连续的存储空间,因此在分配数组空间时,只能预先估计表的大小再 进行存储分配。当表长变化较大时,难以确定数组的合适的大小。 实现表的另一种方法是用指针将存储表元素的那些单元依次串联在一起。这种方法避免了 在数组中用连续的单元存储元素的缺点,因而在执行插入或删除运算时,不再需要移动元素来腾 出空间或填补空缺。然而为此付出的代价是,需要在每个单元中设置指针来表示表中元素之间 的逻辑关系,因而增加了额外的存储空间开销。为了将存储表元素的所有单元用指针串联起来, 让每个单元包含一个元素和一个指针,其中指针指向表中下一个元素所在的单元。例如,如果表 是 #(3),#(/),⋯ ,#("),那么含有元素 #($)的那个单元中的指针应指向含有元素 #($ 4 3)的 单元($ 5 3,/,⋯ ," 6 3)。含有 #(")的那个单元中的指针是空指针。上述这种用指针来表示表 的结构通常称为单链接表,或简称为单链表或链表。单链表的逻辑结构如图 /0 / 所示。 图 /0 /2 单链表 单链表的结点结构说明为: %78)9): ’%";<% $-9) !=#$>; %78)9): ’%";<% $-9) {&#’%(%)* )=)*)$%;=#$> $)?%;}@-9); =#$> @).@-9)() { 2 2 =#$> 8; 2 2 #:((8 "*A==-<(’#B)-:(@-9))))""C) !" 数据结构与算法 ! ! ! ! "##$#(("%&’()*+, -+-$#./ (); ! ! +0)+ #+*(#1 2; } 其中,34)*5*+- 表示用户指定的元素类型。其数据成员 +0+-+1* 存储表中元素;1+%* 是指向表中下 一个元素的指针。函数 6+76$,+()用于产生一个新结点。据此可定义用指针实现表的结构 34)* 如下。 *.2+,+8 )*#(9* 004)* !34)*; *.2+,+8 )*#(9* 004)*{041: 84#)*;}304)*; 表 34)* 的数据成员 84#)* 是指向表中第一个元素的指针,当表为空表时 84#)* 指针是空指针。 函数 34)*514*()创建一个空表。 34)* 34)*514*() { ! 34)* 3 "-’00$9()4;+$8 !3); ! 3 #$84#)* "<; ! #+*(#1 3; } 函数 34)*"-2*.(3)测试当前表 ! 是否为空,这只要看表首指针 84#)* 是否为空指针。 41* 34)*"-2*.(34)* 3) { ! #+*(#1 3 #$84#)* ""<; } 函数 34)*3+1=*&(3)通过对表 ! 进行线性扫描计算表的长度。 41* 34)*3+1=*&(34)* 3) { ! 41* 0+1 "<; ! 041: 2; ! 2 "3 #$84#)*; ! 7&40+(2){0+1 &&;2 "2 #$1+%*;} ! #+*(#1 0+1; } !"第 > 章! 表 ! ! 算法 "#$%"&’(%) 显然需要 !(")计算时间。事实上,如果像用数组实现表那样,将当前表长 用一个数据成员 " 记录,就可在 !(*)时间内实现 "#$%"&’(%)(")运算。 函数 "#$%+&%,#&-&( .,")从表首开始逐个元素向后进行线性扫描直至找到表 # 中第 $ 个 元素。 "#$%/%&0 "#$%+&%,#&-&(#’% .,"#$% ") { ! #’% #; ! 1#’. 2; ! #3(. %*)4,,5,((56% 53 756’8$(); ! 2 "" #$3#,$%;# "*; ! 9)#1&(# %. :: 2){2 "2 #$’&;%;# &&;} ! ,&%6,’ 2 #$&1&0&’%; } 算法 "#$%+&%,#&-& 显然需要 !($)计算时间。 算法 "#$%"5<=%&(;,")用与 "#$%+&%,#&-&(.,")类似的方法从表首开始逐个元素向后进行线性 扫描直至找到表 # 中元素 %。在最坏情况下,算法 "#$%"5<=%& 需要 !(")计算时间。 #’% "#$%"5<=%&("#$%/%&0 ;,"#$% ") { ! #’% # "*; ! 1#’. 2; ! 2 "" #$3#,$%; ! 9)#1&(2 :: 2 #$&1&0&’% !";){2 "2 #$’&;%;# &&;} ! ,&%6,’ 2 ?# :>; } 在单链表 # 中位置 $ 处插入一个元素 % 的算法 "#$%/’$&,%(.,;,")可实现如下。首先扫描链 表找到插入位置 $ 处的结点 &,然后建立一个存储待插入元素 % 的新结点 ’,再将结点 ’ 插入到 结点 & 之后,如图 ?@ A 所示。 图 ?@ A! 在单链表中插入一个元素 !" 数据结构与算法 !"#$ %#&’()&*+’(#)’ ,,%#&’(’*- .,%#&’ %) { / 0#), 1,2; / #)’ #; / #3(, %4)5++"+(("6’ "3 7"6)$&(); / 1 "% #$3#+&’; / 3"+(# "8;# %, 99 1;# &&)1 "1 #$)*.’;/ / !!找插入位置!! / 2 ":*;:"$*(); / 2 #$*0*-*)’ ".; / #3(,){2 #$)*.’ "1 #$)*.’;1 #$)*.’ "2;}/ !!在位置 1 处插入!! / *0&* {2 #$)*.’ "% #$3#+&’;% #$3#+&’ "2;}/ !!在表首插入!! } 算法 %#&’()&*+’(,,.,%)的主要计算时间用于寻找正确的插入位置,故其所需计算时间为 !(")。 算法 %#&’<*0*’*(,,%)分别处理以下 = 种情况。!" > 8 或链表为空;"删除的是表首元素, 即 " ? 8;#删除非表首元素,即 " @ 8。 遇情况!则表中不存在第 " 个元素,给出越界信息。遇情况"则直接修改表首指针 3#+&’,删 除表首元素。遇情况#则先找到表中第 " A 8 个元素所在结点 B,然后再修改结点 B 的指针域, 删除第 " 个元素所在的结点 1,如图 CD E 所示。 图 CD E/ 在单链表中删除一个元素 %#&’(’*- %#&’<*0*’*(#)’ ,,%#&’ %) { / 0#), 1,B; / %#&’(’*- .; / #)’ #; / #3(, %8 ’’ !% #$3#+&’)5++"+(("6’ "3 7"6)$&(); / 1 "% #$3#+&’; / #3(, ""8)% #$3#+&’ "1 #$)*.’;/ !!删除表首元素 !! / *0&* {/ B "% #$3#+&’;/ / / / / !!找第 , #8 个元素所在结点 B/ !! / / / / 3"+(# "8;# %, #8 99 B;# &&)B "B #$)*.’; !"第 C 章/ 表 ! ! ! ! " "# #$$%&’;! ! ! ! ! !!第 ( 个元素所在结点 !! ! ! ! ! # #$$%&’ "" #$$%&’;}! !!删除结点 " !! ! & "" #$%)%*%$’;+,%%(");! ! ! !!保存第 ( 个元素并释放结点 " !! ! ,%’-,$ &; } 算法 ./0’1%)%’%((,.)的主要计算时间用于寻找待删除元素所在结点,故其所需计算时间为 !(")。 算法 ./0’2$0%,’((,&,.)和算法 ./0’1%)%’%((,.)都对表首元素进行特殊处理。事实上,可以 为每一个表设置一个空表首单元或哨兵单元 3%45%,,其中的指针指向开始元素 #(6)所在的单 元,但表首单元 3%45%, 中不含任何元素。这样就可以简化在表首进行插入与删除操作等边界情 况的处理。 输出表 . 中所有元素的算法 7,/$’./0’(.)实现如下: 89/5 7,/$’./0’(./0’ .) { ! )/$( "; ! +9,(" ". #$+/,0’;";" "" #$$%&’)2’%*:39;(" #$%)%*%$’); } <= >! 用间接寻址方法实现表 用数组实现表的方法利用了数组单元在物理位置上的邻接关系表示表中元素之间的逻辑关 系。这一特点使得用数组来实现表时,可以方便地随机存取表中任一位置的元素,且无须为表示 表中元素之间的逻辑关系增加额外的存储空间开销。 用指针实现表,将表中元素用指针依次串联在一起。表的这种实现方法在修改表中元素间 的逻辑关系时,只要修改相应指针而不需要实际移动表中元素。 间接寻址方法是将数组和指针结合起来实现表的一种方法。它将数组中原来存储元素的地 方改为存储指向元素的指针。图 <= ? 是用间接寻址方法实现表的示意图。 图 <= ?! 间接寻址 !" 数据结构与算法 用间接寻址方法实现表的结构说明如下: !"#$%$& ’()!*!$+!,%%-; !"#$%$& )!-./! (0%1()! !’()!; !"#$%$& )!-./! (0%1()!{ 2 2 2 (0! 0; 2 2 2 (0! +,3)(4$; 2 2 2 ,%%- !!,51$;2 2 2 !!指向表中元素的指针数组 !! }*0%1()!; 其中,! 为表长,+,3)(4$ 是指针数组最大长度,!,51$ 是指向表中元素的指针数组。 函数 ’()!*0(!()(4$)创建一个最大长度为 )(4$ 的空表。 ’()! ’()!*0(!((0! )(4$) { 2 ’()! ’ "+,116/()(4$6& !’); 2 ’ #$0 "7;’ #$+,3)(4$ ")(4$; 2 ’ #$!,51$ "+,116/()(4$!)(4$6&(,%%-)); 2 -$!.-0 ’; } 简单的表运算 ’()!8+#!"(’)和 ’()!’$09!:(’)显然均只需 "(;)计算时间。 (0! ’()!8+#!"(’()! ’) { 2 -$!.-0 ’ #$0 ""7; } (0! ’()!’$09!:(’()! ’) { 2 -$!.-0 ’ #$0; } 表运算 ’()!<$!-($=$(>,’)和 ’()!’6/,!$(3,’)也很容易实现。 ’()!*!$+ ’()!<$!-($=$((0! >,’()! ’) { 2 (&(> %; ’’ > $’ #$0)8--6-((6.! 6& 56.0%)(); !"第 ? 章2 表 ! "#$%"& !’ #$$()*#[+ #,]; } ’-.$/#$"-#0#(+,’)返回表 ! 的位置 " 处的元素。表 ! 中没有位置 " 时,给出错误信息。算 法 ’-.$/#$"-#0# 显然只需 #(,)计算时间。 -&$ ’-.$’12($#(’-.$3$#4 5,’-.$ ’) { ! -&$ -; ! 61"(- "7;- %’ #$&;- &&) ! ! -6(!’ #$$()*#[-] ""5)"#$%"& &&-; ! "#$%"& 7; } ’-.$’12($#(5,’)返回元素 $ 在表 ! 中的位置,当元素 $ 不在表 ! 中时返回 7。该算法在数组 $()*# 中从前向后通过比较来查找给定元素的位置,其基本运算是比较两个元素。若在表中位置 % 找到给定元素,则需要进行 % 次比较,否则需要进行 & 次比较,& 为表的长度。因此在最坏情况 下算法 ’-.$’12($#(5,’)需要 #(&)时间。 以下主要讨论表元素的插入和删除运算的实现。 表元素插入运算 ’-.$3&.#"$(+,5,’)实现方法如下: 01-8 ’-.$3&.#"$(-&$ +,’-.$3$#4 5,’-.$ ’) { ! -&$ -; ! -6(+ %7 ’’ + $’ #$&)9""1"((1%$ 16 )1%&8.(); ! -6(’ #$& ""’ #$4(5.-:#)9""1"((1%$ 16 4#41";(); ! 61"(- "’ #$& #,;- $"+;- ##)’ #$$()*#[- &,]"’ #$$()*#[-]; ! ’ #$$()*#[+]"<#=<18#(); ! !’ #$$()*#[+]"5; ! ’ #$& &&; } 与用数组实现表的情形类似,算法 ’-.$3&.#"$(+,5,’)将位于 " > ,,⋯ ,& 处的元素指针分别 移到位置 " > ?,⋯ ,& > , 处,然后将指向新元素 $ 的指针插入位置 " > , 处。与用数组实现表的 不同之处是这里不实际移动元素而只移动指向元素的指针。虽然该算法所需的计算时间仍为 #("),但在每个元素占用的空间较大时,该算法比用数组实现的表的插入算法快得多。 表元素删除运算 ’-.$@#*#$#(+,’)可实现如下: !" 数据结构与算法 !"#$%$&’ !"#$(&)&$&("*$ +,!"#$ !) { , "*$ ";!"#$%$&’ -;.//0 1; , "2(+ %3 ’’ + $! #$*)40050((56$ 52 756*/#(); , 1 "! #$$.7)&[+ #3]; , - "!1; , 250(" "+;" %! #$*;" &&)! #$$.7)&[" #3]"! #$$.7)&["]; , ! #$* ##; , 20&&(1); , 0&$60* -; } 算法 !"#$(&)&$&(+,!)通过将位于 ! 8 3,! 8 9,⋯ ," 处的元素指针移到位置 !,! 8 3,⋯ ," : 3 来删除原来位置 + 处的元素。该算法的时间分析与插入算法类似,元素指针的移动次数也是 # (!)。同样与用数组实现表的不同之处是这里不实际移动元素而只移动指向元素的指针。虽然 该算法所需的计算时间仍为 #(!),但在每个元素占用的空间较大时,该算法比用数组实现的表 元素删除算法快得多。 输出表中所有元素的函数 ;0"*$!"#$(!)实现如下: <5"/ ;0"*$!"#$(!"#$ !) { , "*$ "; , 250(" "=;" > ! #$*;" &&), %$&’?@5A(!! #$$.7)&["]); } 9B C, 用游标实现表 所谓游标就是数组中指示数组单元地址的下标值,属于整数类型。本小节讨论用数组和指针 相结合,并用游标来模拟指针的方法来实现表。在这种表示法下,数组单元类型 ?*5/& 定义为: $D1&/&2 #$06E$ #*5/& !)"*+; $D1&/&2 #$06E$ #*5/& {!"#$%$&’ &)&’&*$;"*$ *&-$;}?*5/&; 其中,&)&’&*$ 域存储表中元素;*&-$ 是用于模拟指针的游标,它指示表中下一个元素在数组中的 存储地址(数组下标)。用游标模拟指针可方便地实现单链表中的各种运算。虽然是用数组来 存储表中的元素,但在作表的插入和删除运算时,不需要移动表中元素,只要修改游标,从而保持 了用指针实现表的优点。因此,有时将这种用游标实现的表称为静态链表。, !"第 9 章, 表 为了实现游标对指针的模拟,必须先设计模拟内存管理的结点空间分配与释放运算,模拟 ! 语言的函数 "#$$%&(和 ’()))。为此定义模拟空间结构类型 *+#&) 如下: ,-+).)’ /,(0&, /+#&) !*+#&); ,-+).)’ /,(0&, /+#&){ 1 23, 30",’2(/,; 1 $234 3%.);1 1 1 1 !!可用空间数组 !! }*2"0$; 其数据成员 30" 表示可用数组空间大小;3%.)[5:30"]是供分配的可用数组,初始时所有单元均 可分配;’2(/, 是当前可用数组空间中的第 6 个可用数组单元下标。对于 *+#&) 类型结构 / 中的可 用空间,用函数 *+#&)7$$%&#,)(/)每次从当前可用数组空间中分配一个数组单元。函数 *+#&)8)9 #$$%&#,)(2,/)则每次将一个不用的数组单元 2 放回 / 的当前可用数组空间中供下次分配使用。 函数 *+#&):32,("#;)创建一个可用数组空间最大长度为 "#; 的模拟空间结构如下: *+#&) *+#&):32,(23, "#;) { 1 23, 2; 1 *+#&) / ""#$$%&(/2<)%’ !/); 1 / #$30" ""#;; 1 / #$3%.) ""#$$%&("#;!(/2<)%’ !/ #$3%.))); 1 ’%((2 "5;2 %"#; #6;2 &&)1 1 1 1 / #$3%.)[2]= 3);, "2 &6;1 1 !!初始化可用数组空间链 !! 1 / #$3%.)["#; #6]= 3);, " #6;1 1 !!可用数组空间链的最后一个结点 !! 1 / #$’2(/, "5;1 1 1 1 1 1 !!可用数组空间链的第一个可分配结点 !! 1 (),0(3 /; } 从 / 的当前可用数组空间中分配一个数组单元的函数 *+#&)7$$%&#,)(/)实现如下: 23, *+#&)7$$%&#,)(*+#&) /) { 1 23, 2; 1 2 "/ #$’2(/,; 1 / #$’2(/, "/ #$3%.)[2]= 3);,; 1 (),0(3 2; } !" 数据结构与算法 释放 ! 的数组单元 " 的函数 #$%&’(’%))*&%+’(",!)实现如下: ,*"- #$%&’(’%))*&%+’(".+ ",#$%&’ !) { / ! #$.*-’["]0 .’1+ "! #$2"3!+; / ! #$2"3!+ ""; } 容易看出上述 4 个函数所需的计算时间分别为 !(.56),!(7)和 !(7)。采用下面的双可 用空间表方法可省去构造函数所需的计算时间 !(.56)。该方法用两个可用空间表来表示当前 可用数组空间。其中第 7 个可用空间表中含有所有未用过的可用数组单元;第 8 个可用空间表 中含有所有至少被用过 7 次且已被释放的可用数组单元。#$%&’(’%))*&%+’ 释放的所有单元均链 入第 8 个可用空间表中备用。#$%&’9))*&%+’ 在分配一个可用数组单元时,总是先从第 8 个可用 空间表中获取可用数组单元。仅当第 8 个可用空间表为空时才从第 7 个可用空间表中获取可用 数组单元。 在双可用空间表类中用 2"3!+7 指向第 7 个可用空间表的表首可用单元,2"3!+8 指向第 8 个可 用空间表的表首可用单元,实现方法如下: +:$’-’2 !+35&+ -!$%&’ !#$%&’; +:$’-’2 !+35&+ -!$%&’{ / ".+ .56,2"3!+7,2"3!+8; / )".; .*-’; }(!$%&’; 在这种表示法下,创建初始可用数组空间的函数得以简化,实现方法如下: #$%&’ #$%&’<."+(".+ 6%1) { / #$%&’ ! "6%))*&(!"=’*2 !!); / ! #$.56 "6%1; / ! #$.*-’ "6%))*&(6%1!(!"=’*2 !! #$.*-’)); / ! #$2"3!+7 ">; / ! #$2"3!+8 " #)* / +,-.+/ 0* 1 从当前可用数组空间中分配一个数组单元的函数 #$%&’9))*&%+’(!)相应修改如下: !!第 8 章/ 表 !"# $%&’()**+’&#(($%&’( ,) { - !"# !; - !.(, #$.!/,#0 "" #1)- - - - !!第 0 个可用空间表为空 !! - - /(#2/" , #$.!/,#1 &&; - ! ", #$.!/,#0;- - - - - - - !!从第 0 个可用空间表分配 !! - , #$.!/,#0 ", #$"+3([!]4 "(5#; - /(#2/" !; } 图 04 6- 用游标实现表 释放数组单元 ! 的函数 $%&’(7(&**+’&#((!,,)也作相应修改如下: 8+!3 $%&’(7(&**+’&#((!"# !,$%&’( ,) { - , #$"+3([!]4 "(5# ", #$.!/,#0; - , #$.!/,#0 "!; } 在上述讨论的基础上,用游标实现的表结构 9!,# 说明如下: #:%(3(. ,#/2’# ,*!,# !9!,#; #:%(3(. ,#/2’# ,*!,#{ - - - !"# .!/,#;- - - !!表首结点游标 !! - - - $%&’( ,; }$*!,#; 9!,# 的数据成员 .!/,# 是表首结点游标;, 表示可用数组 空间。图 04 6 是用游标实现的表的示意图。其中表 ) 含 有 ; 个元素;表 < 含有 0 个元素。 函数 9!,#="!#(,!>()申请大小为 ,!>( 的模拟空间,并置 表首结点游标 .!/,# 为 ? 1,创建一个空表,实现方法如下: 9!,# 9!,#="!#(!"# ,!>() { - 9!,# 9 "@&**+’(,!>(+. !9); - 9 #$, "$%&’(="!#(,!>(); - 9 #$.!/,# " #1; - /(#2/" 9; !" 数据结构与算法 } 函数 !"#$!%&’$((!)通过对表 ! 进行线性扫描计算表的长度,实现方法如下: "&$ !"#$!%&’$((!"#$ !) { ) "&$ ",*%&; ) " "! #$+",#$;) ) ) !!当前表结点游标 !! ) *%& "-; ) .("*%(" !" #/){ ) ) *%& &&; ) ) " "! #$# #$&01%["]2 &%3$; ) ) } ) ,%$4,& *%&; } 与单链表的情形类似,函数 !"#$5%$,"%6%(7,!)从表首开始逐个元素向后进行线性扫描直至 找到表中第 " 个元素。它需要 #(")计算时间。实现方法如下: !"#$8$%9 !"#$5%$,"%6%("&$ 7,!"#$ !) { ) "&$ :," "/; ) "+(7 %/);,,0,((04$ 0+ <04&1#(); ) : "! #$+",#$; ) .("*%(" %7 == : !" #/){) ) ) !!搜索第 7 个元素 !! ) ) ) : "! #$# #$&01%[:]2 &%3$; ) ) ) " &&; ) ) ) } ) ,%$4,& ! #$# #$&01%[:]2 %*%9%&$; } 函数 !"#$!0>?$%(3,!)用与 !"#$5%$,"%6%(7,!)类似的方法从表首开始逐个元素向后进行线性 扫描直至找到表 ! 中元素 $。在最坏情况下,它需要 #(%)计算时间。实现方法如下: "&$ !"#$!0>?$%(!"#$8$%9 3,!"#$ !) { ) "&$ :," "/; ) : "! #$+",#$; !"第 @ 章) 表 ! "#$%&(’ !" #( )) * #$+ #$,-.&[’]/ &%&0&,1 !"2){! ! ! !!从左到右 !! ! ! ! ’ "* #$+ #$,-.&[’]/ ,&21; ! ! ! $ &&; ! ! ! } ! 3&143,((’ $"5)?$ :5); } 要在当前表的第 ! 个元素之后插入一个新元素 ",应先找到插入位置,即当前表的第 ! 个元 素所处位置。然后从可用数组空间中为新元素分配一个存储单元,并将由此产生的新结点插入 表的第 ! 个结点之后。实现方法如下: 6-$. *$+17,+&31($,1 8,*$+171&0 2,*$+1 *) { ! ! $,1 ’,9,$; ! ! $:(8 %5);33-3((-41 -: <-4,.+(); ! ! ’ "* #$:$3+1;! ! ! ! ! ! ! ! ! ! !!搜索游标 !! ! ! :-3($ "(;$ %8 )) ’!" #(;$ &&) ! ! ! ’ "* #$+ #$,-.&[’]/ ,&21;! ! ! ! !!搜索第 8 个元素 !! ! ! 9 "=’>?&@%%-?>1&(* #$+);! ! ! ! !!为新元素分配存储空间 !! ! ! * #$+ #$,-.&[9]/ &%&0&,1 "2; ! ! ! ! ! ! ! ! ! ! ! !!将新结点插入表的第 8 个结点之后 !! ! ! $:(8){! ! ! ! ! ! !!非表首结点 !! ! ! ! * #$+ #$,-.&[9]/ ,&21 "* #$+ #$,-.&[’]/ ,&21; ! ! ! * #$+ #$,-.&[’]/ ,&21 "9;} ! ! &%+& {! ! ! ! ! ! !!表首结点 !! ! ! ! * #$+ #$,-.&[9]/ ,&21 "* #$:$3+1; ! ! ! * #$:$3+1 "9;} } 由于上述结构未使用哨兵表首单元,所以必须单独处理在表的第 ( 个位置插入的情形。算 法 *$+17,+&31 的主要计算时间用于寻找正确的插入位置,故其所需计算时间为 #(!)。 要删除当前表的第 ! 个元素,同样应先找到当前表的第 ! 个元素所处位置。然后将存储该 元素的那个单元摘除,并释放该单元到可用数组空间中备用。实现方法如下: *$+171&0 *$+1A&%&1&($,1 8,*$+1 *) { ! ! $,1 ’,B,$; ! ! *$+171&0 2; !" 数据结构与算法 ! ! "#($ %% ’’ & #$#"’() "" #%)*’’+’((+,) +# -+,./((); ! ! 0 "& #$#"’();! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! !!搜索游标 !! ! ! !!搜索第 $ 个元素 !! ! ! "#($ ""%)& #$#"’() "& #$( #$.+/1[0]2 .13);! ! !!删除表首元素 !! ! ! 14(1 {! ! ! ! ! ! ! ! ! !!找表中第 $ #% 个元素所在结点 !! ! ! ! 5 "0; ! ! ! #+’(" "%;" %$ #% 66 5 !" #%;" &&)5 "& #$( #$.+/1[5]2 .13); ! ! ! 0 "& #$( #$.+/1[5]2 .13);! !!第 $ 个元素所在结点 !! ! ! ! & #$( #$.+/1[5]2 .13) "& #$( #$.+/1[0]2 .13);! !!删除结点 0 !! ! ! ! } ! 3 "& #$( #$.+/1[0]2 14171.);! ! !!保存删除元素 !! ! 809:1;1944+:9)1(0,& #$();! ! ! !!释放单元空间 !! ! ’1),’. 3; } 与单链表情形类似,算法 &"();141)1($,&)的主要计算时间用于寻找待删除元素所在结点,故 其所需计算时间为 !(")。 输出表中所有元素的函数 <’".)&"()(&)实现如下: =+"/ <’".)&"()(&"() &) { ! ".) 0; ! #+’(0 "& #$#"’();0 !" #%;0 "& #$( #$.+/1[0]2 .13)) ! ! ! >)178?+@(& #$( #$.+/1[0]2 14171.)); } A2 B! 循 环 链 表 在用指针实现表时,表中最后一个元素所在单元的指针为空指针。如果将这个空指针改为 指向表首单元的指针就使整个链表形成一个环。这种首尾相接的链表就称为循环链表。在循环 链表中,从任意一个单元出发可以找到表中其他单元。图 A2 C 所示的是一个单链的循环链表或 简称为单循环链表,其中图 A2 C9 为非空表,图 A2 C- 为空表。 在单循环链表上实现表的各种运算的算法与单链表的情形是类似的。它们的不同之处仅在 于循环终止条件不是 0 或 0 #$.13) 是否为空,而是判断它们是否指向表首单元。在单链表中用指 向表首单元的指针表示一个表,这样就可以在 !(%)时间内找到表中的第一个元素。然而要找 到表中最后一个元素就要花 !(#)时间遍历整个链表。在单循环链表中,也可以用指向表首单 元的指针表示一个表。但是,如果用指向表尾的指针表示一个表,就可以在 !(%)时间内找到表 !"第 A 章! 表 图 !" #$ 单循环链表 中最后一个元素。同时通过表尾单元中指向表首单元的指针,也可以在 !(%)时间内找到表中 的第一个元素。在许多情况下,用这种表示方法可以简化一些关于表的运算。$ $ $ 上述单循环链表的结构说明如下: &’()*)+ ,&-./& /01,& !21,&; &’()*)+ ,&-./& /01,&{ $ $ 13& 3;$ $ $ $ !!表的长度 !! $ $ 0134 05,&;$ $ $ !!指向表尾的指针 !! }601,&; 函数 21,&731&()创建一个空表,实现方法如下: 21,& 21,&731&() { $ 0134 ’; $ 21,& 2 "85009/(,1:)9+ !2); $ ’ ";)<;9*)(); $ ’ #$3)=& "’; $ 2 #$05,& "’; $ 2 #$3 > ?; $ -)&.-3 2; } 由于存储了表的长度,函数 21,&@8(&’(2)和 21,&2)3A&B(2)变得十分容易。 函数 21,&C)&-1)D)(4,2)仍需从表中第 % 个元素开始逐个元素向后扫描直至找到表中第 " 个 元素,实现方法如下: 21,&7&)8 21,&C)&-1)D)(13& 4,21,& 2) { $ 13& 1 "%; $ 0134 (; $ 1+(4 %% ’’ 4 $2 #$3)@--9-((9.& 9+ E9.3*,(); !" 数据结构与算法 ! " "# #$$%&’ #$()*’ #$()*’; ! +,-$)(- %. ){" "" #$()*’;- &&;} ! /)’0/( " #$)$)1)(’; } 算法 #-&’2)’/-)3)(.,#)显然需要 !(")计算时间。 函数 #-&’#45%’)(*,#)用与 #-&’2)’/-)3)(.,#)类似的方法从表中第 6 个元素开始逐个元素向 后进行线性扫描直至找到表中元素 #。在最坏情况下,算法 #-&’#45%’)(*,#)需要 !($)计算时 间。实现方法如下: -(’ #-&’#45%’)(#-&’7’)1 *,#-&’ #) { ! -(’ - "6; ! $-(. "; ! " "# #$$%&’ #$()*’ #$()*’; ! # #$$%&’ #$()*’ #$)$)1)(’ "*; ! +,-$)(" #$)$)1)(’ !"*){" "" #$()*’;- &&;} ! /)’0/((( " ""# #$$%&’ #$()*’)?8 :-); } 在循环链表中插入一个新元素的算法与单链表的情形类似。由于采用了表首哨兵单元,简 化了算法对表首插入的边界情形的处理。实现方法如下: 34-9 #-&’7(&)/’(-(’ .,#-&’7’)1 *,#-&’ #) { ! $-(. ",:; ! -(’ -; ! -;(. %8 ’’ . $# #$())+>49)();! ! ! ! ! ! ! !!插入新元素 !! ! : #$)$)1)(’ "*; ! : #$()*’ "" #$()*’; ! " #$()*’ ":; ! -;(. ""# #$()# #$$%&’ ? :; ! # #$( &&; } !"第 @ 章! 表 与单链表情形类似,算法 !"#$%&#’($(),*,!)的主要计算时间用于寻找正确的插入位置,故其 所需计算时间为 !(")。 在循环链表中删除第 " 个元素的算法也与单链表的情形类似。同样由于采用了表首哨兵单 元,简化了算法对删除表中第 + 个元素的边界情形的处理。实现方法如下: !"#$%$’, !"#$-’.’$’("&$ ),!"#$ !) { / ."&) 0,1; / !"#$%$’, *; / "&$ "; / "2() %+ ’’ ) $! #$&)3((4(((45$ 42 645&7#(); / 1 "! #$.8#$ #$&’*$ #$&’*$; / 24((" "+;" %) #+ ;" &&)1 "1 #$&’*$;/ !!搜索第 ) #+ 个元素 !! / 0 "1 #$&’*$;/ !!第 ) 个元素 !! / 1 #$&’*$ "0 #$&’*$; / "2() ""! #$&)! #$.8#$ "1; / * "0 #$’.’,’&$; / 2(’’(0); / ! #$& ##; / (’$5(& *; } 与单链表情形类似,算法 !"#$-’.’$’(),!)的主要计算时间用于寻找待删除元素所在结点,故 其所需计算时间为 !(")。 输出表中所有元素的函数 9("&$!"#$(!)实现如下: :4"7 9("&$!"#$(!"#$ !) { / ."&) 0; / 24((0 "! #$.8#$ #$&’*$ #$&’*$;0!"! #$.8#$ #$&’*$;0 "0 #$&’*$) / / / %$’,;<4=(0 #$’.’,’&$); } >? @/ 双/ 链/ 表 在单循环链表中,虽然从表的任一结点出发,可以找到其前驱结点,但需要 !(#)时间。如 果希望能快速确定表中任一元素的前驱和后继元素所在的结点,可以在链表的每个结点中设置 !" 数据结构与算法 ! 个指针,一个指向后继结点,另一个指向前驱结点,形成图 !" # 所示的双向链表或简称为双链 表。 图 !" #$ 双链表 双链表的结点类型定义为: %&’()(* +%,-.% /0)( !12/3; %&’()(* +%,-.% /0)( {42+%5%(6 (1(6(/%;12/3 1(*%,,278%;}90)(; 其数据成员 (1(6(/% 存储表中元素;1(*% 是指向前一结点的指针;,278% 是指向后一结点的 指针。 用双链表实现表的结构类型定义为: %&’()(* +%,-.% )12+% !42+%; %&’()(* +%,-.% )12+%{ $ $ 2/% /; $ $ 12/3 1(*%:/),,278%:/); };12+%; 其数据成员 ! 存储表的长度;1(*%:/) 是指向表首的指针;,278%:/) 是指向表尾的指针。 和单循环链表类似,双链表也可以有循环表。用一个表首哨兵结点 8(<)(, 将双链表首尾相 接,即将表首哨兵结点中的 1(*% 指针指向表尾,并将表尾结点的 ,278% 指针指向表首哨兵结点,构 成如图 !" = 所示的双向循环链表。 图 !" =$ 双向循环链表 用双向循环链表实现表的结构类型定义为: %&’()(* +%,-.% )12+% !42+%; %&’()(* +%,-.% )12+%{ $ $ 2/% /; $ $ 12/3 8(<)(,; };12+%; !"第 ! 章$ 表 其中 !"#$"% 是指向表首哨兵结点的指针。 函数 &’()*+’)()创建一个仅由表首哨兵结点组成的空双向循环链表。实现方法如下: &’() &’()*+’)() { , -’+. /; , &’() & "0#--12((’3"14 !&); , / "5"651$"(); , / #$-"4) "/; , / #$%’7!) "/; , & #$!"#$"% "/; , & #$+ "8; , %")9%+ &; } 函数 &’():")%’";"(.,&)从表 ! 中第 < 个元素开始逐个元素向后扫描直至找到表中第 " 个元 素。实现方法如下: &’()*)"0 &’():")%’";"(’+) .,&’() &) { , ’+) ’ "<; , -’+. =; , ’4(. %< ’’ . $& #$+)>%%1%((19) 14 ?19+$((); , = "& #$!"#$"% #$%’7!); , 6!’-"(’ %. ){= "= #$%’7!);’ &&;} , %")9%+ = #$"-"0"+); } 与单链表的情形一样,算法 &’():")%’";"(.,&)需要 #(")计算时间。 函数 &’()&12#)"(@,&)用与 &’():")%’";"(.,&)类似的方法从表中第 < 个元素开始逐个元素向 后进行线性扫描直至找到表中元素 $。在最坏情况下,算法 &’()&12#)"(@,&)需要 #(%)计算时 间。实现方法如下: ’+) &’()&12#)"(&’()*)"0 @,&’() &) { , ’+) ’ "<; , -’+. =; , = "& #$!"#$"% #$%’7!); !" 数据结构与算法 ! " #$#$%&$’ #$$($)$*+ ",; ! -#.($(/ #$$($)$*+ !",){/ "/ #$’.0#+;. &&;} ! ’$+1’*((/ """ #$#$%&$’)?2 :.); } 在双链表中进行插入或删除运算时,要修改向前和向后 3 个方向的指针。 在双向循环链表的第 ! 个元素之后插入一个新元素 " 的算法可实现如下: 45.& ".6+7*6$’+(.*+ 8,".6+7+$) ,,".6+ ") { ! (.*8 /,9; ! .*+ .; ! .:(8 %2 ’’ 8 $" #$*);’’5’((51+ 5: <51*&6(); ! / "" #$#$%&$’ #$’.0#+; ! :5’(. "=;. %8 ;. &&)/ "/ #$’.0#+;! !!搜索第 8 个元素 !! ! 9 ">$->5&$();! ! ! ! ! ! ! ! ! !!插入新元素 !! ! 9 #$$($)$*+ ",; ! 9 #$($:+ "/; ! 9 #$’.0#+ "/ #$’.0#+; ! / #$’.0#+ #$($:+ "9; ! / #$’.0#+ "9; ! " #$* &&; } 上述算法对链表指针的修改情况见图 3? =2。与单链表情形类似,算法 ".6+7*6$’+(8,,,")的 主要计算时间用于寻找正确的插入位置,故其所需计算时间为 #(!)。 图 3? =2! 在双链表中插入一个元素 删除双向循环链表中第 ! 个元素的算法可实现如下: ".6+7+$) ".6+@$($+$(.*+ 8,".6+ ") { !"第 3 章! 表 ! "#$% &; ! ’#()*)+, -; ! #$) #; ! #.(% %/ ’’ % $’ #$$)01121((23) 2. 423$5((); ! & "’ #$6+75+1 #$1#86); ! .21(# "/;# %% ;# &&)& "& #$1#86);! !!搜索第 % 个元素 !! ! & #$"+.) #$1#86) "& #$1#86);! ! ! ! !!修改指针,实施删除 !! ! & #$1#86) #$"+.) "& #$"+.); ! - "& #$+"+,+$); ! .1++(&); ! ’ #$$ ##; ! 1+)31$ -; } 上述算法对链表指针的修改情况见图 9: //。算法 ’#();+"+)+(%,’)的主要计算时间用于寻 找待删除元素所在结点,故其所需计算时间为 !(")。 图 9: //! 从双向循环链表中删除一个元素 与单链表中的删除算法类似,上述算法是在已知要删除元素在链表 # 中的位置 " 时,将该位 置所指的结点删去。若要从一个表 # 中删除一个元素 $,但不知道它在表中的位置时,应先用定 位函数 ’#()’2<7)+(-,’)找出要删除元素的位置,然后再用 ’#();+"+)+ 删除这个元素。 可以用游标来模拟指针,实现用游标表示的双向链表和循环链表。 输出双向循环链表中所有元素的函数 =1#$)’#()(’)实现如下: >2#5 =1#$)’#()(’#() ’) { ! "#$% &; ! .21(& "’ #$6+75+1 #$1#86);&!"’ #$6+75+1;& "& #$1#86)) ! ! ! *)+,?62@(& #$+"+,+$)); } 9: A! 表的搜索游标 在对表进行各种操作时,常需要对表进行顺序扫描。为了使这种顺序扫描具有通用性,可以 !! 数据结构与算法 将与之相关的运算定义为抽象数据类型表的基本运算。常用的有如下基本运算: ! !"#$!%&"(’):初始化搜索游标。 " !"#$(#)"(’):当前搜索游标的下一个位置。如果当前搜索游标在表尾,则其下一个位置 为表首。 # *+$$!"#,(’):当前搜索游标处的表元素。 $ !%-#$"*+$$(),’):在当前搜索游标处插入元素 !。 % .#/#"#*+$$(’):删除当前搜索游标处的元素。 下面讨论对于不同的表结构实现搜索游标的方法。 !" #" $% 用数组实现表的搜索游标 在用数组实现的表结构中增加一个数据成员 0+$$,用于记录当前搜索游标的值。实现方法 如下: "12#3#4 -"$+0" 5/&-" !’&-"; "12#3#4 -"$+0" 5/&-"{ 6 6 6 &%" %,0+$$; 6 6 6 &%" ,5)-&7#; 6 6 6 ’&-"!"#, !"58/#; }9/&-"; 函数 !"#$!%&"(’)将表 " 的搜索游标初始化为表首元素的位置。实现方法如下: :;&3 !"#$!%&"(’&-" ’) { 6 ’ #$0+$$ "<; } !"#$(#)"(’)将搜索游标从当前位置移向下一个位置。实现方法如下: :;&3 !"#$(#)"(’&-" ’) { 6 ’ #$0+$$ "(’ #$0+$$ &=)> ’ #$%; } 函数 *+$$!"#,(’)返回表 " 的当前将搜索游标处的元素。实现方法如下: ’&-"!"#, !*+$$!"#,(’&-" ’) !"第 ? 章6 表 { ! "#$%"& ’( #$$)*+#[( #$,%""]; } -&.#"$/%""(0,()在表 ! 的当前搜索游标处插入元素 "。实现方法如下: 1234 -&.#"$/%""((3.$-$#5 0,(3.$ () { ! (3.$-&.#"$(( #$,%"",0,(); } 函数 6#+#$#/%""(()删除并返回表 ! 的当前搜索游标处的元素。实现方法如下: (3.$-$#5 6#+#$#/%""((3.$ () { ! (3.$-$#5 0 "(3.$6#+#$#(( #$,%"" &7,(); ! ( #$,%"" "( #$,%""8 ( #$&; ! "#$%"& 0; } !" #" !$ 单循环链表的搜索游标 在单循环链表结构中增加一个数据成员 ,%"",用于记录当前搜索游标指针。实现方法如下: $9:#4#; .$"%,$ ,+3.$ !(3.$; $9:#4#; .$"%,$ ,+3.${ ! ! 3&$ &; ! ! +3&< +).$,,%""; }/+3.$; 函数 -$#"-&3$(()将表 ! 的搜索游标初始化为指向表首哨兵结点的指针。实现方法如下: 1234 -$#"-&3$((3.$ () { ! ( #$,%"" "( #$+).$ #$�$; } -$#"=#0$(()将搜索游标指针下移。实现方法如下: !" 数据结构与算法 !"#$ %&’()’*&(+#,& +) { - + #$./(( "+ #$./(( #$0’*&; - #1(+ #$./(( ""+ #$23,&)+ #$./(( "+ #$./(( #$0’*&; } 函数 4/((%&’5(+)返回表 ! 的当前将搜索游标处的元素。实现方法如下: +#,&%&’5 !4/((%&’5(+#,& +) { - #1(+ #$0 ""6)(’&/(0 6; - ’2,’ (’&/(0 7+ #$./(( #$0’*& #$’2’5’0&; } %0,’(&4/((( *,+)在表 ! 的当前搜索游标处插入元素 "。由于 ./(( 指针已指向当前结点 的前一结点,所以直接在 ./(( 指针处插入新结点,而不必调用 +#,&%0,’(& 实现插入。实现方 法如下: !"#$ %0,’(&4/(((+#,&%&’5 *,+#,& +) { - 2#08 9 ")’:)"$’(); - 9 #$’2’5’0& "*; - 9 #$0’*& "+ #$./(( #$0’*&; - + #$./(( #$0’*& "9; - #1(+ #$./(( ""+ #$23,&)+ #$23,& "9; - + #$0 &&; } 函数 ;’2’&’4/(((+)删除并返回表 ! 的当前搜索游标处的元素。与函数 %0,’(&4/(((*,+)相 同,由于 ./(( 指针已指向当前结点的前一结点,所以直接修改指针删除当前结点,而不必调用 +#,&;’2’&’ 实现删除。实现方法如下: +#,&%&’5 ;’2’&’4/(((+#,& +) { - 2#08 <,=; - +#,&%&’5 *; - = "+ #$./((; !"第 > 章- 表 ! " "# #$$%&’; ! # #$$%&’ "" #$$%&’; ! ()(" ""* #$+,-’){* #$+,-’ "#;* #$./00 "* #$./00 #$$%&’;} ! & "" #$%+%1%$’; ! )0%%("); ! * #$$ ##; ! 0%’/0$ &; } 对于本章介绍的其他几种实现表的方法,可以用类似的方法增加搜索游标功能。 23 4! 应! ! 用 56-%"7/- 排列问题定义如下:假设 ! 个竞赛者排成一个环形。给定一个正整数 ""!,从某 个指定的第 8 个人开始,沿环计数,每遇到第 " 个人就让其出列,且计数继续进行下去。这个过 程一直进行到所有的人都出列为止。最后出列者为优胜者。每个人出列的次序定义了整数 8, 2,⋯ ,! 的一个排列。这个排列称为一个(!,")56-%"7/- 排列。 例如,(9,:)56-%"7/- 排列为 :,;,2,9,<,8,=。 用本章介绍的抽象数据类型表可以设计一个求( !,")56-%"7/- 排列的算法。实现方法 如下: >6(? 56-%"7/-(($’ $,($’ 1) { ! ($’ (,@; ! *(-’A’%1 &; ! *(-’ + "*(-’A$(’();! ! ! ! ! ! ! ! ! ! ! ! ! ! !!创建一个空表 + !! ! )60(( "8;( %"$;( &&)*(-’A$-%0’(( #8,(,+);! ! !!表 + 中第 ( 个元素为 ( !! ! A’%0A$(’(+);! ! ! ! ! ! ! ! ! ! ! ! ! !!初始化表 + 的搜索游标 !! ! )60(( "8;( %$;( &&){ ! ! )60(@ "8;@ %1;@ &&)A’%0B%&’(+);! ! ! ! !!循环计数 !! ! ! & "C%+%’%D/00(+);! ! ! ! ! ! ! ! ! !!删除第 1 个元素 !! ! ! "0($’)((C%+%’% .6$’%-’,$’ E ? 2$(,&);! !!输出被删除元素 !! ! ! } ! "0($’)((D6$’%-’,$’ E ?! F($- ’7% .0/(-% 2$(,!D/00A’%1(+)); } 注意,上述算法中重要的一点是搜索游标的下一个位置 A’%0B%&’(+)是循环定义的,即当搜索 !" 数据结构与算法 游标在表尾时,其下一个位置又回到表首。这使得表 ! 在逻辑上构成一个循环链表。 有时在解一个具体问题时可以充分利用问题的特点,采用简洁的数据结构设计出高效算法。 例如,对于上面讨论的 "#$%&’($ 排列问题,表 ! 中第 ! 个元素为 !。可见表 ! 是一个非常特殊的 表。利用问题的这个特点,结合用游表实现表的方法,可以设计更简洁的算法如下: )#*+ "#$%&’($(*,- ,,*,- .) { / *,- *,0,1,!,%2-; / ,%2- ".3!!#4(,!$*5%#6(*,-)); / 6#7(* "8;* %, #9;* &&)),%2-[*]"* &9;/ / / / !!第 * 个元素的下一元素为 * &9 !! / 1 ", #9;,%2-[1]"8;/ / / / / / / !!/ 1 为搜索游标 !! / 6#7(* "9;* %,;* &&){ / / 6#7(0 "9;0 %.;0 &&)1 ",%2-[1];/ !!循环计数 !! / / &7*,-6((:%!%-% 4#,-%$-3,- ; + 2,(,,%2-[1]&9);/ !!输出第 . 个元素 !! / / ,%2-[1]",%2-[,%2-[1]];/ / / / !!删除第 . 个元素 !! / / } / &7*,-6((<#,-%$-3,- ; +/ =*,$ -’% 47(*$% 2,(,,%2-[1]&9); } 上述算法利用问题特殊性,用数组下标表示表中元素,使得数据结构更加紧凑。 本 章 小 结 本章介绍了抽象数据类型表的基本概念及其逻辑特征。简要阐述了实现抽象数据类型的一 般步骤。按照抽象数据类型设计和实现的一般性原则,详细介绍了实践中常用的实现表的方法, 如用数组实现表的方法、用指针实现表的方法、用间接寻址技术实现表的方法、用游标实现表的 方法,以及单循环链表和双链表的实现方法和步骤。基于实现表的各种方法,讨论搜索游标的概 念和实现方法,进一步扩充了表的功能。最后,以 "#$%&’($ 排列问题为例讨论了表的应用方法。 本章介绍的概念和方法在后续各章中还会反复用到。 习/ / 题 !" #$ 用数组实现表的一个缺点是需要预先估计表的大小。克服这个缺点的一个方法是在初始化时先将 数组大小 .32$*5% 置为 9,其后在插入一个元素时,如果表已满,就重新分配一个大小为 >!.32$*5% 的数组,将 表中元素复制到新数组中,并删除老数组。类似地,在删除一个元素后,如果表的大小已降至 9 ? .32$*5%,就重 新分配一个大小为 9 > .32$*5% 的新数组,将表中元素复制到新数组中,并删除老数组。 (9)用上述思想重新设计用数组实现表的结构。 !"第 > 章/ 表 (!)设 !" ,!! ,⋯ ,!" 是从空表开始的 " 个表运算组成的序列。如果教材中用原数组实现表的方法执行此运 算序列需要计算时间为 #("),试证明用本题实现表的方法执行此运算序列最多需要计算时间 $#("),其中 $ 是 一个常数。 !" !# 解决习题 !# " 中提出的数组空间分配问题的另一种方法是预先为数组分配一个较大的空间,让多个 表共享这一数组空间。采用这种方法在设计算法时就应考虑多个表在同一数组空间中协调共存的问题。试用 上述思想重新设计用数组实现表的一个结构。 !" $# 设表的 $%&%’(% 运算将表中元素的次序反转。 扩充用数组实现表的结构 )*(+,增加函数 $%&%’(%())将表 % 中元素的次序反转,并要求就地实现 $%&%’(% 运 算。 !" %# 扩充用数组实现的表 )*(+ 的功能,增加一函数 ,-./())。该函数删去当前表 % 中相隔的元素,使表的 大小减半。例如,设当前表 % 的表元素数组为 +-0.%[]1[",!,2,3,4]。则执行 ,-./())后 +-0.%[]1[",2,4]。 设计实现 ,-./())的算法并分析其计算时间复杂性。 !" &# 许多实际应用需要反复在一个表中前后移动,为此,需要对表的搜索游标增加如下一些运算: (")5+%’6%+(7,)):设置表 % 的搜索游标为第 & 个元素位置。 (!)8*’(+5+%9()):返回表 % 中第一个元素的位置。 (2))-(+5+%9()):返回表 % 中最后一个元素的位置。 (3)5+%’:’%&*;<(()):将搜索游标前移一个位置。 试用数组实现表的方法实现上述扩充的搜索游标功能。 !" ’# 设 ’ 和 ( 均为用数组实现的 )*(+ 类型的表,试设计一个函数 =.+%’>-+%(=,?),从表 ’ 中第 " 个元素开 始,交替地用表 ’ 和表 ( 中元素组成一个新表。 例如,如果设表 ’ 为 )("),)(!),⋯ ,)(");表 ( 为 *("),*(!),⋯ ,*(+),则执行 =.+%’>-+%(=,?)运算得到 的新表为:)("),*("),)(!),*(!),⋯ !" (# 设 ’ 和 ( 均为用数组实现的 )*(+ 类型的表,且设 ’ 和 ( 中元素是按非增序排列的。 试设计一个函数 @%’A%(=,?),将有序表 ’ 和 ( 合并为一个新的有序表,并分析算法的计算复杂性。 !" )# 试设计用数组实现表 )*(+ 的函数 6B.*+(=,?,)),根据表 % 创建 ! 个新表 ’ 和 (,其中 ’ 包含表 % 中奇 数位置上的所有元素,( 包含其余元素。 !" *# 在用数组实现表时,若将表中第 & 个元素存储于 +-0.%[7]中,则有些表的运算会更简单些。试用此方 式重写结构 )*(+ 及实现基本抽象数据类型功能的函数。 !" +,# 扩充用指针实现的表 )*(+,增加函数 8’;9())和 C;())。其中 8’;9())将一个用数组实现的表 % 变 换为用指针实现的表;C;())将一个用指针实现的表变换为用数组实现的表。 !" ++# 对用指针实现的表,重做习题 !# 2。 !" +!# 对用指针实现的表,重做习题 !# D。 !" +$# 对用指针实现的表,重做习题 !# E。要求实现时不改变表 ’ 和表 ( 中元素占用的空间位置。 !" +%# 对用指针实现的表,重做习题 !# F。 !" +&# 为用间接寻址方法实现的表增加搜索游标功能。 !" +’# 为用游标实现的表增加搜索游标功能。 !" +(# 二进位的 G;’(异或)运算$定义为 , $ - . HI , . - "I , % { - 二进位串的 G;’(异或)运算$定义为按位 G;’ 运算。 例如,设 ) 1 "H""H,* 1 H""HH,则 ) $ * 1 ""H"H。 G;’ 运算$具有以下性质: !" 数据结构与算法 ! $(! $ ")!(! $ !)$ " ! " (! $ ")$ " ! ! $(" $ ")! ! 由此性质可设计用游标模拟指针实现双链表的结构如下: 用数组 " 存储表中元素。每个数组单元有 # 个域 $%$&$’( 和 %)’*。$%$&$’( 域用于存储元素,%)’* 域用于存储 游标。%)’* 域中游标的含义如下: 设 #、$ 和 % 是 表 中 相 继 + 个 元 素,$ 是 # 的 后 继 且 % 是 $ 的 后 继。 它 们 分 别 存 储 于 "[)], $%$&$’(,"[-], $%$&$’(和 "[*], $%$&$’( 中,则 "[-], %)’* 中存储的游标值是 & $ ’。当 "[-]是表首元素时 & ! .;当 "[-]是表尾元素时 ’ ! .。 (/)试设计用上述方法实现的双链表。 (#)设计一个从表首到表尾遍历上述双链表各结点并依次输出表中元素的算法。 (+)设计一个从表尾到表首遍历上述双链表各结点并依次输出表中元素的算法。 !" #$% 对单循环链表,重做习题 #, +。 !" #&% 对单循环链表,重做习题 #, 0。 !" !’% 对单循环链表,重做习题 #, 1。 !" !#% 对单循环链表,重做习题 #, 2。 !" !!% 设 3 是指向单循环链表 ( 中某一结点的指针。试设计一个在 )(/)时间内删除 3 所指结点中元素的 算法。(提示:由于不知道 3 的前驱结点指针,因此难以在 )(/)时间内删除 3 所指结点。但可在 )(/)时间内确 定 3 的后继结点 4,用 4 的 (56%$ 域内容替换 3 的 (56%$ 域内容,然后删除结点 4 可达到同样目的。) !" !(% 对双链表,重做习题 #, +。 !" !)% 对双链表,重做习题 #, 0。 !" !*% 对双链表,重做习题 #, 1。 !" !+% 对双链表,重做习题 #, 2。 !" !,% 试设计一个算法,对于给定的正整数 * 和 ’,/"’"*,求正整数 +"*,使(*,+)789$3:;9 排列的最后 一个数是 ’。 !" !$% 设计一个表示高精度整数的结构,它支持对任意大整数的输入、输出和四则运算( < 、= 、> 、? ),其 中除法运算应输出所得的商和余数。 !" !&% 设计一个表示一元多项式 ,. - ,/ # - ⋯ - ,* #* 的结构,它支持关于一元多项式的下列运算: (/)@8%AB’)(():创建一个 . 阶多项式。 (#)C$DE$$(@):多项式 . 的阶数。 (+)B’3;((@):输入一个多项式 .。 (F)G;(3;((@):输出多项式 .。 (H)IJJ(@,K):多项式加法。 (0)";6(@,K):多项式减法。 (1)L;%(@,K):多项式乘法。 (2)C)M(@,K):多项式除法。 (N)O5%(P,@):多项式 . 在 # 处的值。 !"第 # 章Q 表 书书书 第 ! 章" 栈 学习目标 ! 理解栈是满足 !"#$ 存取原则的表。 ! 熟悉定义在抽象数据类型栈上的基本运算。 ! 掌握用数组实现栈的步骤和方法。 ! 掌握用指针实现栈的步骤和方法。 ! 理解用栈解决实际问题的方法。 %& ’( )*+ 栈 栈是一种特殊的表,这种表只在表首进行插入和删除操作。因此,表首对于栈来说具有特殊 的意义,称为栈顶。相应地,表尾称为栈底。不含任何元素的栈称为空栈。 图 %& ’( 栈的示意图 假设一个栈 ! 中元素为 "(#),"(# , ’),⋯ ,"(’),则称 "(’) 为栈底元素,-(#)为栈顶元素。栈中元素按 "(’),"(.),⋯ ,"(#) 的次序进栈。在任何时候,出栈的元素都是栈顶元素,换句话说, 栈的修改是按后进先出的原则进行的,如图 %& ’ 所示。因此,栈又 称为后进先出(!-/0 "1 #23/0 $40)表,简称为 !"#$ 表。栈的这个特 点可以用一叠摞在一起的盘子形象地比喻。要从这一叠盘子中取 出或放入一个盘子,只有在这一叠盘子的顶部操作才最方便。 栈也是一个抽象数据类型。常用的栈运算有: ! 50-6789:0;(5):测试栈 ! 是否为空。 " 50-67#4<<(5):测试栈 ! 是否已满。 # 50-67+=:(5):返回栈 ! 的栈顶元素。 $ >4/?(@,5):在栈 ! 的栈顶插入元素 $,简称为将元素 $ 入栈。 % >=:(5):删除并返回栈 ! 的栈顶元素,简称为抛栈。 栈的应用非常广泛,只要问题满足 !"#$ 原则,就可以使用栈。下面来看一个应用栈的简单 例子。 在对高级语言编写的程序进行编译时会遇到表达式或字符串的括号匹配问题。例如 ! " " 程序中左、右花括号“{”和“}”的匹配问题。表达式(字符串)的括号匹配问题要求确定一给定 表达式(字符串)中左、右括号的匹配情况。例如,表达式(#!(# " $)% &)在位置 ’ 和 ( 处有左括 号,在位置 ) 和 ’’ 处有右括号。位置 ’ 处的左括号与位置 ’’ 处的右括号相匹配;位置 ( 处的左 括号与位置 ) 处的右括号相匹配。而在表达式(# " $)!&)(中,位置 ) 处的右括号没有可匹配的 左括号,位置 * 处的左括号没有可匹配的右括号。 对于给定的表达式 +#,-,从左到右逐个字符进行扫描可发现,每个右括号与最近遇到的尚未 匹配的左括号相匹配。由此容易想到下面的算法,在从左到右逐个字符对给定的表达式 +#,- 进 行扫描的过程中,将所遇到的左括号存入一个栈中。每当扫描到一个右括号时,如果栈非空,就 将其与栈顶的左括号相匹配,并从栈顶删除该左括号;如果栈已空,则所遇到的右括号不匹配。 在完成对表达式的扫描后,如果栈仍非空,则留在栈中的左括号均不匹配。 基于上述思想的表达式括号匹配算法描述如下: ./01 23-+456707(863- !+#,-) {9 !!表达式括号匹配算法 !! 9 045 0,4; 9 :538; 77 ":538;<405(); 9 4 "75-=+4(+#,-); 9 >/-(0 "’;0 #"4;0 $$){9 9 9 9 9 !!扫描表达式中的左右括号 !! 9 9 9 0>(+#,-[0 %’]""&(&)2?76(0,77); 9 9 9 +=7+ 0>(+#,-[0 %’]""&)&){ 9 9 9 9 0>(:538;@A,5$(77)) 9 9 9 9 9 ,-045>(’位置B 1 处的右括号不匹配(4’,0); 9 9 9 9 +=7+ ,-045>(’B 19 B 1 (4’,2/,(77),0);9 !!配对的左括号 !! 9 9 9 9 } 9 9 9 } 9 !!栈中剩余的左括号不匹配 !! 9 C60=+(!:538;@A,5$(77)) 9 9 9 ,-045>(’位置B 1 处的左括号不匹配(4’,2/,(77)); } 在算法 23-+456+707 中,栈 77 是一个整数栈,用于存放未匹配左括号在字符串 +#,- 中的位置。! 是字符串 +#,- 的长度。由算法的扫描过程可知,算法 23-+456+707 的计算时间复杂性为 "(!)。 DE F9 用数组实现栈 栈是一个特殊的表,可以用数组来实现。考虑到栈运算的特殊性,用一个数组 1353 存储栈 !"第 D 章9 栈 元素时,栈底固定在数组的底部,即 !"#"[$]为最早入栈的元素,并让栈向数组上方(下标增大的 方向)扩展。 用数组实现的栈结构 %#"&’ 定义如下: #()*!*+ ,#-.&# ",#"&’ !%#"&’; #()*!*+ ,#-.&# ",#"&’{ / / 01# #2),/ / / !!栈顶位置 !! / / / / 3"4#2);/ !!栈顶位置的最大值 !! %#"&’5#*3 !"#";/ !!栈元素数组 !! }6,#"&’; 在上述栈结构 %#"&’ 的定义中,栈元素存储在数组 !"#" 中。用 #2) 指向当前栈顶位置。栈顶 元素存储在 !"#"[#2)]中。栈的容量为 3"4#2)。 函数 %#"&’510#(,07*)创建一个容量为 ,07* 的空栈,实现方法如下: %#"&’ %#"&’510#(01# ,07*) { / %#"&’ % "3"882&(,07*2+ !%); / % %)!"#" "3"882&(,07*!,07*2+(%#"&’5#*3)); / % %)3"4#2) ",07*; / % %)#2) " %9; / -*#.-1 %; } 当 #2) : ; 9 时当前栈为空栈。 01# %#"&’<3)#((%#"&’ %) { / / -*#.-1 % %)#2) #$; } 当 #2) "3"4#2) 时当前栈满。 01# %#"&’=.88(%#"&’ %) { / / -*#.-1 % %)#2) )"% %)3"4#2); } !" 数据结构与算法 栈顶元素存储在 !"#"[#$%]中。 &#"’()#*+ &#"’(,$%(&#"’( &) { - ./(&#"’(0+%#1(&))022$2(’&#"’( .3 *+%#1’); - *43* 2*#526 & %)!"#"[& %)#$%]; } 新栈顶元素 7 应存储在 !"#"[#$% $8]中。 9$.! :53;(&#"’()#*+ 7,&#"’( &) { - - ./(&#"’(<544(&))022$2(’&#"’( .3 /544’); - - *43* & %)!"#"[ $$& %)#$%]"7; } 删除栈顶元素后,新栈顶元素在 !"#"[#$% = 8]中。 &#"’()#*+ :$%(&#"’( &) { - - ./(&#"’(0+%#1(&))022$2(’&#"’( .3 *+%#1’); - - *43* 2*#526 & %)!"#"[& %)#$% %%]; } 在一些算法中使用栈时,常需要同时使用多个栈。为了使每个栈在算法运行过程中不会溢 出,通常要为每个栈预置一个较大的栈空间,但这一点并不容易做到,因为各个栈在算法运行过 程中实际所用的最大空间很难估计。另一方面,各个栈的实际大小在算法运行过程中不断变化, 经常会发生其中一个栈满,而另一个栈空的情形。如果能让多个栈共享空间,将提高空间的利用 率,并减少发生栈上溢的可能性。 假设让程序中的 > 个栈共享一个数组 !"#"[?:6]。利用栈底位置不变的特性,可以将 > 个栈 的栈底分别设在数组 !"#" 的 > 端,然后各自向数组 !"#" 的中间伸展,如图 @A > 所示。这 > 个栈的 栈顶初值分别为 ? 和 !。当 > 个栈的栈顶相遇时才可能发生上溢。由于 > 个栈之间可以互补余 缺,因此每个栈实际可用的最大空间往往大于 ! B >。 图 @A >- 共享同一数组空间的 > 个栈 !!第 @ 章- 栈 !" !# 用指针实现栈 在算法中要用到多个栈时,最好用链表作为栈的存储结构,即用指针来实现栈。用这种方式 实现的栈也称为链栈,见图 !" !。 图 !" !# 链栈 链栈的结点类型定义为: $%&’(’) *$+,-$ *./(’ !*01.2; $%&’(’) *$+,-$ *./(’ {3$4-25$’6 ’0’6’.$;*01.2 .’7$;}3$4-28/(’; *01.2 8’93$4-28/(’() { # # *01.2 &; # # 1)((& "6400/-(*1:’/)(3$4-28/(’)))"";) # # # # <++/+(’<7=4,*$’( 6’6/+%" ’); # # ’0*’ +’$,+. &; } 其数据成员 ’0’6’.$ 存储栈元素;.’7$ 是指向下一个结点的指针。函数 8’93$4-28/(’( )创 建一个新结点。 用指针实现的链栈 3$4-2 定义如下: $%&’(’) *$+,-$ 0*$4-2 !3$4-2; $%&’(’) *$+,-$ 0*$4-2 { # # *01.2 $/&;# !!栈顶结点指针 !! }>*$4-2; 其中,$/& 是指向栈顶结点的指针。 函数 3$4-25.1$()将 $/& 置为空指针,创建一个空栈,实现方法如下: 3$4-2 3$4-25.1$() { !" 数据结构与算法 ! "#$%& " "’$(()%(*+,-). !"); ! " %)#)/ "0; ! 1-#213 "; } 函数 "#$%&4’/#5(")简单地检测指向栈顶的指针 #)/ 是否为空指针,实现方法如下: +3# "#$%&4’/#5("#$%& ") { ! 1-#213 " %)#)/ ""0; } 函数 "#$%&62(((")通过 "#$%&7-’62((()为栈 ! 试分配一个新结点,检测栈空间是否已满,实 现方法如下: +3# "#$%&62((("#$%& ") { ! ! 1-#213 "#$%&7-’62(((); } +3# "#$%&7-’62((() { ! ! *(+3& /; ! ! +.((/ "’$(()%(*+,-).("#$%&8)9-)))""0)1-#213 :; ! ! -(*- {.1--(/);1-#213 0;} } 函数 "#$%&;)/(")返回栈 ! 的栈顶结点中的元素,实现方法如下: "#$%&<#-’ "#$%&;)/("#$%& ") { ! +.("#$%&4’/#5("))411)1(’"#$%& +* -’/#5’); ! -(*- 1-#213 " %)#)/ %)-(-’-3#; } 函数 =2*>(?,")先为元素 " 创建一个新结点,然后修改 ! 的栈顶结点指针 #)/,使新结点成 为新栈顶结点,实现方法如下: !"第 @ 章! 栈 !"#$ %&’(()*+,-.*/0 1,)*+,- )) { 2 2 ’3#4- 5; 2 2 #6()*+,-7&33()))899"9(’)*+,- #’ 6&33’); 2 2 5 ":/;)*+,-:"$/(); 2 2 5 %)/3/0/4* "1; 2 2 5 %)4/1* ") %)*"5; 2 2 ) %)*"5 "5; } 函数 %"5())先将 ! 的栈顶元素存于 " 中,然后修改栈顶指针使其指向栈顶元素的下一个元 素,从而删除栈顶元素,最后返回 ",实现方法如下: )*+,-.*/0 %"5()*+,- )) { 2 2 ’3#4- 5;)*+,-.*/0 1; 2 2 #6()*+,-805*<()))899"9(’)*+,- #’ /05*<’); 2 2 1 ") %)*"5 %)/3/0/4*; 2 2 5 ") %)*"5; 2 2 ) %)*"5 "5 %)4/1*; 2 2 69//(5); 2 2 9/*&94 1; } => ?2 应2 2 用 集合上的等价关系是一个自反、对称、传递的关系。也就是说,如果"是集合 ! 上的等价关 系,那么对于 ! 中的任意元素 "、#、$(它们可能相同),下述性质成立: ! """(自反性)。 " 如果 ""#,则 #""(对称性)。 # 如果 ""#,#"$,则 ""$(传递性)。 等于关系“ @ ”是一种特殊的等价关系。对于集合 ! 中任意元素 "、#、$,有: ! " @ "。 " 如果 " @ #,则 # @ "。 # 如果 " @ #,# @ $,则 " @ $。 除了等于关系外,还有许多等价关系。一般地,如果将集合划分成若干个互不相交的子集, !" 数据结构与算法 再定义 ! 上的关系"如下:""# 的充要条件是 " 与 # 属于同一子集,则"是等价关系。等于关系 就是每个子集只含一个元素的特殊情况。反之,如果集合 ! 上已经定义了一个等价关系,那么 ! 可以被划分成互不相交的子集 !(!),!("),⋯ 每个 !($)都由 ! 中互相等价的元素所组成,即 "" # 的充分且必要的条件是 " 和 # 在集合 ! 的同一子集中。每个 !($)称为集合 ! 的一个等价类。 例如,整数集合上的模 % 同余关系是一个等价关系。事实上 " & " ’ # 是 % 的倍数(自反性)。 如果 " & # ’ (#%,那么 # & " ’( & ()#%(对称性)。 如果 " & # ’ (#%,# & ) ’ *#%,那么 " & ) ’(( + *)#%(传递性)。 此时整数集合被划分为 % 个等价类。 等价类划分问题的提法为:给定集合 ! 及一系列形如“" 等价于 #”的等价性条件,要求给出 ! 的符合所列等价性条件的等价类划分。 例如,给定集合 ! ${!,",⋯ ,%}及等价性条件:!"",&"’,("),!"),对集合 ! 作等价类 划分如下:首先将 ! 的每一个元素看成一个等价类,然后顺序地处理所给的等价性条件。每处理 一个等价性条件,所得到的相应等价类列表如下: !""* * {!,"}* * {(}* * {)}* * {&}* * {’}* * {%} &"’ {!,"} {(} {)} {&,’} {%} (") {!,"} {(,)} {&,’} {%} !") {!,",(,)}{&,’} {%} 最终所得到的集合 ! 的等价类划分为:{!,",(,)}{&,’}{%}。 在许多情况下,可以用整数来表示集合中的元素。如果集合 ! 中共有 % 个元素,可将集合 ! 表示为{!,",⋯ ,%}。等价性条件表示为 $",,!$$,,$%。利用抽象数据类型栈,可按下述方式来 解集合 ! 的等价类划分问题。 首先,为集合 ! 中每个元素 $ 建立一个链表 +[,],!$$$%。顺序地处理所给的等价性条件, 使 +[,]中包含所有由等价性条件显式给出的与 $ 等价的元素,然后对链表进行处理,找出所有隐 含的等价关系。实现方法如下: -.,/ 01,2() {* !!集合的等价类划分算法 !! * ,23 1,4,,,5,2,6,!7,!.83; * +,93 !+; * :31;< 931;< ":31;<=2,3(); * !!输入集合中元素个数 2;等价性条件数 6 !! * >6,23?(’集合中元素个数(2’);9;12?(’@ /’,A2); * ,?(2 #"){>6,23?(’集合中元素个数太少(2’);BC,3(!);} * >6,23?(’等价性条件数(2’);9;12?(’@ /’,A6); * ,?(6 #!){>6,23?(’等价性条件数太少(2’);BC,3(!);} !"第 ( 章* 栈 ! !!为集合中每个元素建立一个链表 !! ! " "#$%%&’((( $))!*+,-&.(!")); ! .&/(+ ");+ #"(;+ $$)"[+]""+*01(+0(); ! !!顺序处理所给等价性条件 !! ! .&/(+ ");+ #"/;+ $$){ ! ! ! 2/+(0.(’输入等价性条件((’); ! ! ! *’$(.(’3 43 4’,5$,56); ! ! ! "+*01(*-/0(7,6,"[$]); ! ! ! "+*01(*-/0(7,$,"[6]); ! ! ! } ! !!对链表进行处理,找出所有隐含的等价关系 !! ! &80 "#$%%&’((( $))!*+,-&.(+(0)); ! .&/(+ ");+ #"(;+ $$)&80[+]"7; ! !!输出等价类 !! ! .&/(+ ");+ #"(;+ $$){ ! ! +.(!&80[+]){! !!新等价类 !! ! ! ! ! 2/+(0.(’等价类:! 3 4((’,+); ! ! ! ! &80[+]"); ! ! ! ! 98*:(+,*0$’;); ! ! ! ! !!从栈中取等价类中元素 !! ! ! ! ! <:+%-(!=0$’;>#20?(*0$’;)){ ! ! ! ! ! @ "9&2(*0$’;); ! ! ! ! ! !!"[@]中元素属于同一等价类 !! ! ! ! ! ! 10-/1(+0("[@]);! !!搜索游标 !! ! ! ! ! ! A "B8//10-#("[@]); ! ! ! ! ! 6 ""+*0"-(C0:("[@]); ! ! ! ! ! .&/($ ");$ #"6;$ $$){ ! ! ! ! ! ! ! +.(!&80[!A]){! !!A 属于同一等价类 !! ! ! ! ! ! ! ! ! ! ! =0$’;=:&<(!A); ! ! ! ! ! ! ! ! ! ! &80[!A]"); ! ! ! ! ! ! ! ! ! ! 98*:(!A,*0$’;);} ! ! ! ! ! ! ! 10-/D-E0("[@]); ! ! ! ! ! ! ! A "B8//10-#("[@]); ! ! ! ! ! ! ! } ! ! ! ! ! } !" 数据结构与算法 ! ! ! ! "#$%&’(’ (%’); ! ! ! ! } ! ! } } 算法在对链表进行处理时用一个数组 ()& 记录等价类成员的输出状态。当 ()&[$]* + 时表 示元素 $ 已输出。栈 ,&-./ 用于收集并输出同一等价类中的所有元素。栈 ,&-./ 为空时表示当前 等价类中所有元素均已输出,此时应转入下一等价类。为了找下一等价类中的第一个元素,算法 继续扫描数组 ()&,直至找到集合中尚未输出的元素。如果找到集合中的一个尚未输出的元素, 就将该元素存入栈 ,&-./ 同时转入下一轮循环。如果没找到集合中的尚未输出的元素,则表明集 合中所有元素均已输出,算法终止。 上述算法所需的计算时间显然为 !(" # $),其中 " 是集合中的元素个数,$ 是等价性条件数。 本 章 小 结 本章介绍了抽象数据类型栈的基本概念及其逻辑特征。按照抽象数据类型设计和实现的一 般性原则,详细介绍了实践中常用的用数组实现栈的方法和用指针实现栈的方法。最后以集合 的等价类划分问题为例讨论了栈的应用方法。本章介绍的抽象数据类型栈在后续各章中还会反 复用到。 习! ! 题 !" #$ 扩充抽象数据类型栈的定义,增加如下栈运算: (+)0&-./0$12(0):确定栈 % 中元素个数。 (3)0&-./4%(0):输入栈 %。 (5)0&-./6)&(0):输出栈 %。 用数组实现扩充后的栈。 !" %$ 扩充抽象数据类型栈的定义,增加如下栈运算: (+)0&-./0"7$&(0+,03):将栈 0+ 分为大小相同的 3 个栈 0+ 和 03。栈 03 中含原栈 0+ 中上半部分元素,其余 元素留在栈 0+ 中。 (3)0&-./8(9:$%2(0+,03):将栈 03 中元素合并于栈 0+ 顶部,且保持原栈中元素次序。03 成为空栈。 用数组实现上述扩充后的栈。 !" !$ 用指针实现栈的方法重做习题 5; +。 !" &$ 用指针实现栈的方法重做习题 5; 3。 !" ’$ 举 3 个应用抽象数据类型栈的例子。 !" ($ 设有编号为 +、3、5、< 的 < 辆列车,顺序进入一个栈式结构的站台。试写出这 < 辆车开出车站的所有 可能的顺序。 !" )$ 试证明,借助于栈,由输入序列 +,3,⋯ ,",得到的输出序列为 &(+),&(3),⋯ ,&(")(它是输入序列的 一个排列),则在输出序列中不可能出现这样的情形,即存在 ’ = ( = ) 使 &(()= &())= &(’)。 !"第 5 章! 栈 !" #$ 用数组实现栈的一个缺点是需要预先估计栈的大小 !"#$%&。克服这个缺点的一个方法是在初始化时 先将 !"#$%& 置为 ’,其后在插入一个元素时,如果栈已满,就重新分配一个大小为 (#!"#$%& ) * 的数组,将栈中 元素复制到新数组中,并删除老数组。类似地,在删除栈顶元素后,如果栈的大小已降至 * + !"#$%&,就重新分配 一个大小为 * ( !"#$%& 的新数组,将栈中元素复制到新数组中,并删除老数组。 (*)基于上述思想重新设计用数组实现栈的结构。 (()设 !* ,!( ,⋯ ,!" 是从空栈开始的 " 个由 ,-./ 和 ,%& 运算组成的栈运算序列。如果教材中用数组实现栈 的方法执行此运算序列需要 #(")的计算时间,试证明用本题实现栈的方法执行此运算序列最多需要 $#(")计 算时间,其中 $ 是一个常数。 !" %$ 当 ( 个栈共享一个数组 .$"01[’:2]时,试设计在第 % 个栈中加入元素 & 的算法 ,-./(1,#,3),以及删 除并返回第 % 个栈的栈顶元素的算法 ,%&(1,3)。 !" &’$ 图 45 + 所示的数据结构是在一个数组中保存 4 个栈的方法。 图 45 +6 多个栈共享一个数组 类似地可将 % 个栈存于一个数组之中。在这种情况 下,如果第 ’ 个栈的入栈运算 ,-./(7,#,3)将使 8%&(7)9 :%$$%!(7 ; *),那么必须首先移动所有的栈,使它们两两之 间留有适当的空隙。例如,可以使所有栈的上方留有相等 的空隙,或者使各栈上方的空隙与栈的当前长度成正比。 ! 假设已有函数 <=%>? 能在 ( 个栈发生冲突时调整 各栈在数组中的位置,试重新设计用数组实现栈的结构。 " 如果已有函数 @=A8%&. 可以计算出所有新栈顶的 位置 2=A$%&[7],*$’$%,那么如何实现函数 <=%>?。(提 示:对各栈的位置进行调整时,第 ’ 个栈可能向上或向下移 动。若第 ’ 个栈的新位置将与第 (((%’)个栈的老位置重 合,则必须先移动第 ( 个栈,然后才能移动第 ’ 个栈。为了调度各栈的移动次序,可以使用另一个目标栈,其中的 目标就是待移动栈的编号。依次考虑栈号 *,(,⋯ ,%。当考虑第 ’ 个栈时,如果它能够安全移动,则将它移到新 位置,然后再处理目标栈顶的栈号;如果第 ’ 个栈还不能安全地移动,则将 ’ 推入目标栈。) # 如何实现(()中的目标栈,是否必须将它作为整数的表,还有更简捷的表示方法吗? $ 如何实现函数 @=A8%&.,使得每个栈上方留出的空隙与该栈的当前长度成正比? !" &&$ 试用抽象数据类型栈设计一个算法,检测给定 B 语言程序中左、右花括号“{”和“}”是否匹配。 !" &($ 电路板布线问题:矩形电路板的 + 周分布着 " 个针脚 *,(,⋯ ,",用于连接导线。连线(!,))表示针脚 ! 和 ) 之间有一根导线相连。连接针脚的导线只能分布在电路板的矩形区域内。对于给定的一组连线(!* , )* ),(!( ,)( ),⋯ ,(!% ,)% ),如果能在电路板的矩形布线区域内适当安排这组连线,使它们互不相交,则称这组 连线是可布线的。对于给定的 " 个针脚和 % 条连线,试设计一个算法判定这组连线是否为可布线的。 !" 数据结构与算法 书书书 第 ! 章" 队" " 列 学习目标 ! 理解队列是满足 !"!# 存取原则的表。 ! 熟悉定义在抽象数据类型队列上的基本运算。 ! 掌握用指针实现队列的步骤和方法。 ! 掌握用循环数组实现队列的步骤和方法。 ! 理解用队列解决实际问题的方法。 $% &’ ()* 队列 队列是另一种特殊的表,这种表只在表首(称为队首)进行删除操作,在表尾(称为队尾)进 行插入操作。由于队列的修改是按先进先出的原则进行的,所以队列又称为先进先出(!+,-. "/ !+,-. #0.)表,简称 !"!# 表。 假设队列为 !(&),!(1),⋯ ,!("),那么 !(&)就是队首元素,!(")为队尾元素。队列中的元 素是按 !(&),!(1),⋯ ,!(")的顺序进入的。退出队列也只能按照这个次序依次退出。也就是 说,只有在 !(&)离开队列之后,!(1)才能退出队列,只有在 !(&),!(1),⋯ ,!(" 2 &)都离开队列 之后,!(")才能退出队列。图 $% & 是队列的示意图。 图 $% &’ 队列 程序设计中经常会用到队列。操作系统的作业排 队是应用队列的一个典型的例子。在允许多道程序同 时运行的计算机系统中,如果多个作业的运行结果都 需要通过某一通道输出,那么就要按请求输出的先后 次序排队,将这些待输出的作业放入一个队列中。凡 是申请输出的作业从队尾进入队列,每当输出通道空 闲,可以接受新的输出任务时,队首的作业从队列中退出,使用该输出通道作输出操作。 抽象数据类型队列所支持的 3 个基本运算为: ! 40505678.9(4):测试队列 # 是否为空。 ! !"#"#$"%%(!):测试队列 ! 是否已满。 " !"#"#$&’()(!):返回队列 ! 的队首元素。 # !"#"#*+()(!):返回队列 ! 的队尾元素。 $ ,-)#’!"#"#(.,!):在队列 ! 的队尾插入元素 "。 % /#%#)#!"#"#(!):删除并返回队列 ! 的队首元素。 01 23 用指针实现队列 与栈的情形相同,任何一种实现表的方法都可以用于实现队列。用指针实现队列实际上得 到一个单链表。队列结点的类型与单链表结点类型相同。 )45#6#7 ()’"8) 9-:6# !9%&-;; ()’"8) 9-:6# {!<)#= #%#=#-);9%&-; -#.);}!-:6#; 用指针实现的队列 !"#"# 的定义如下: )45#6#7 ()’"8) %9"# !!"#"#; )45#6#7 ()’"8) %9"#{ 3 3 3 9%&-; 7’:-);3 !!队首结点指针 !! 3 3 3 9%&-; ’#+’;3 !!队尾结点指针 !! }*9"#"#; 由于入队的新元素是在队尾进行插入的,所以用一个指针 ’#+’ 来指示队尾可以使入队运算 不必从头到尾检查整个表,从而提高运算的效率。另外,对于 $&’() 和 /#!"#"# 运算需要使用指 向队首的指针 7’:-)。图 01 2 是用指针实现队列的示意图。 图 01 23 用指针实现队列 函数 !"#"#<-&)( )通过将队首指针 7’:-) 和队尾指针 ’#+’ 均置为空指针,从而创建一个空 队列。 !"#"# !"#"#<-&)() { 3 !"#"# ! "=+%%:8((&>#:7 !!); !" 数据结构与算法 ! " #$#$%&’ "" #$$()$ "*; ! $(’+$& "; } 下面讨论队列的基本运算。 函数 "+(+(,-.’/(")简单地检测 #$%&’ 是否为空指针,实现方法如下: 0&’ "+(+(,-.’/("+(+( ") { ! ! $(’+$& " #$#$%&’ ""*; } 函数 "+(+(1+22(")通过 "3(-1+22()为队列 ! 试分配一个新结点,来检测队列空间是否已 满,实现方法如下: 0&’ "+(+(1+22("+(+( ") { ! ! $(’+$& "3(-1+22(); } 0&’ "3(-1+22() { ! ! 420&5 .; ! ! 0#((. "-)22%6(708(%#("&%9()))""*)$(’+$& :; ! ! (27( {#$(((.);$(’+$& *;} } 函数 "+(+(10$7’(")返回队列 ! 的队首结点中的元素,实现方法如下: ";’(- "+(+(10$7’("+(+( ") { ! 0#("+(+(,-.’/(")),$$%$(%"+(+( 07 (-.’/%); ! $(’+$& " #$#$%&’ #$(2(-(&’; } 函数 "+(+(<)7’(")返回队列 ! 的队尾结点中的元素,实现方法如下: ";’(- "+(+(<)7’("+(+( ") !"第 = 章! 队! ! 列 { ! "#($%&%&’()*+($))’,,-,(%$%&%& ". &()*+%); ! ,&*%,/ $ #$,&0, #$&1&(&/*; } 函数 ’/*&,$%&%&(2,$)先为元素 ! 创建一个新结点,然后修改队列 " 的队尾结点指针,在队 尾插入新结点,使新结点成为新队尾结点,实现方法如下: 3-"4 ’/*&,$%&%&($5*&( 2,$%&%& $) { ! 61"/7 ); ! ) "8&9$8-4&();! !!创建一个新结点 !! ! ) #$&1&(&/* "2; ! ) #$/&2* ":; ! !!在队尾插入新结点 !! ! "#($ #$#,-/*)$ #$,&0, #$/&2* ");! ! !!队列非空 !! ! &1.& $ #$#,-/* ");! ! ! ! ! ! ! ! !!空队列 !! ! $ #$,&0, "); } 函数 ;&1&*&$%&%&($)先将队首元素存于 ! 中,然后修改队列 " 的队首结点指针,使其指向 队首结点的下一个结点,从而删除队首结点,最后返回 !。实现方法如下: $5*&( ;&1&*&$%&%&($%&%& $) { ! 61"/7 );$5*&( 2; ! "#($%&%&’()*+($))’,,-,(%$%&%& ". &()*+%); ! !!将队首元素存于 2 中 !! ! 2 "$ #$#,-/* #$&1&(&/*; ! !!删除队首结点 !! ! ) "$ #$#,-/*; ! $ #$#,-/* "$ #$#,-/* #$/&2*; ! #,&&()); ! ,&*%,/ 2; } 以上用指针实现的队列基本运算都只要 #(<)的计算时间。 !! 数据结构与算法 !" #$ 用循环数组实现队列 用数组实现表的方法同样可用于实现队列,可是这样做的效果并不好。尽管可以用一个游 标来指示队尾,使得 %&’()*+(+( 运算在 !(,)时间内完成,但是在执行 -(.(’(*+(+( 时,为了删除 队首元素,必须将数组中其他所有元素都向前移动一个位置。这样当队列中有 " 个元素时,执行 -(.(’(*+(+( 就需要 !(")时间。 为了提高运算的效率,可以采用另一种观点来处理数组中各单元的位置关系。设想数组 /+(+([0:123456(7,]中的单元不是排成一行,而是围成一个圆环,即 /+(+([0]接在 /+(+([1237 456(7,]的后面。这种意义下的数组称为循环数组,如图 !" # 所示。 图 !" #$ 用循环数组实现队列 用循环数组实现队列时,将队列中从队首到队尾的元 素按顺时针方向存放在循环数组的一段连续的单元中。当 需要将新元素入队时,可将队尾游标 )(2) 按顺时针方向移一 位,并在这个单元中存入新元素。出队运算也很简单,只要 将队首游标 8)9&’ 依顺时针方向移一位即可。容易看出,用 循环数组来实现队列可以在 !(,)时间内完成 %&’()*+(+( 和 -(.(’(*+(+( 运算。执行一系列的入队与出队运算,将使 整个队列在循环数组中按顺时针方向移动。 在图 !" # 中,直接用队首游标 8)9&’ 指向队首元素所在 的单元,用队尾游标 )(2) 指向队尾元素所在的单元。另外,也可以用队首游标 8)9&’ 指向队首元 素所在单元的前一个单元(如图 !" !2 所示)或用队尾游标 )(2) 指向队尾元素所在单元的下一个 单元的方法来表示队列在循环数组中的位置(如图 !" !: 所示)。 图 !" !$ 循环数组中的队首与队尾游标 在循环数组中,不论用哪一种方式来指示队首与队尾元素,都要解决一个细节问题,即如何 表示满队列和空队列。例如,在图 !" ; 中,123456( < =,队列中已有 # 个元素,分别用上述 # 种方 法来表示队首和队尾元素,如图 !" ;2、图 !" ;: 和图 !" ;> 所示。 !"第 ! 章$ 队$ $ 列 图 !" #$ 循环数组中的队列 现在又有 % 个元素 !(!)、!(#)、!(&)相继入队,使队列呈“满”的状态,如图 !" &’、图 !" &( 和 图 !" &) 所示。 图 !" &$ 队列满的情形 如果在图 !" # 中,% 个元素 !(*)、!(+)、!(%)相继出队,使队列呈“空”的状态,则如图 !" ,’、 图 !" ,( 和图 !" ,) 所示。 图 !" ,$ 队列空的情形 比较上面图 !" & 和图 !" , 可以看出,不论采用哪一种方式表示队首和队尾元素的位置,都需 要附加说明或约定才能区分满队列和空队列。 通常有两种处理方法来解决这个问题。其一是另设一个布尔量来注明队列是空还是满。其 二是约定当循环数组中元素个数达到 -’./012 3 * 时队列为满。这样,就可以用队列满和队列空 !" 数据结构与算法 时的队首和队尾游标的不同状态来区分这 ! 种情况。例如,在图 "# $ 中,当元素 !(")和 !($)相 继入队后,就使队列呈“满”的状态,如图 "# %&、图 "# %’ 和图 "# %( 所示。 图 "# %) 约定的满队列状态 为明确起见,在下面的讨论中,采用图 "# "& 的队首与队尾游标表示方法,并用上述第 ! 种处 理方法来区分满队列和空队列。 用循环数组实现的队列 *+,+, 定义如下: -./,0,1 2-3+(- &4+, !*+,+,; -./,0,1 2-3+(- &4+,{ ) ) ) 56- 7&8259,;) ) !!循环数组大小 !! ) ) ) 56- 13:6-;) ) ) !!队首游标 !! ) ) ) 56- 3,&3;) ) ) ) !!队尾游标 !! ) ) ) *;-,7 4+,+,;) !!循环数组 !! }<4+,+,; 其中,队首游标 13:6- 和队尾游标 3,&3 的意义如图 "# "& 所示。循环数组 4+,+, 用于存放队列中元 素。 函数 *+,+,;65-(259,)为队列分配一个容量为 259, 的循环数组 4+,+,,并将队首游标 13:6- 和队 尾游标 3,&3 均置为 =,创建一个空队列,实现方法如下: *+,+, *+,+,;65-(56- 259,) { ) *+,+, * "7&>>:((259,:1 !*); ) * #$4+,+, "7&>>:((259,!259,:1(*;-,7)); ) * #$7&8259, "259,; ) * #$13:6- "* #$3,&3 "=; ) 3,-+36 *; } !"第 " 章) 队) ) 列 下面讨论用循环数组实现队列的基本运算。 函数 !"#"#$%&’((!)通过检测队列 ! 的队首游标 )*+,’ 与队尾游标 *#-* 是否重合,来判断队 列 ! 是否为空队列,实现方法如下: .,’ !"#"#$%&’((!"#"# !) { / / *#’"*, ! #$)*+,’ ""! #$*#-*; } 函数 !"#"#0"11(!)通过检测在队列 ! 的队尾插入一个元素后队首游标 )*+,’ 与队尾游标 *#-* 是否重合,来判断队列 ! 是否为满队列,实现方法如下: .,’ !"#"#0"11(!"#"# !) { / / *#’"*,(((! #$*#-* &2)3 ! #$%-45.6# ""! #$)*+,’)?2 :7); } 函数 !"#"#0.*5’(!)返回队列 ! 的队首元素。由于队首游标 )*+,’ 指向队首元素的前一位 置,所以队首元素在循环数组 8"#"# 中的下标是()*+,’ 9 2)3 %-45.6#。实现方法如下: !:’#% !"#"#0.*5’(!"#"# !) { / .)(!"#"#$%&’((!))$**+*(%!"#"# .5 #%&’(%); / *#’"*, ! #$8"#"#[(! #$)*+,’ &2)3 ! #$%-45.6#]; } 函数 !"#"#;-5’(!)返回存储在队列 ! 的 8"#"#[*#-*]中的队尾元素。实现方法如下: !:’#% !"#"#;-5’(!"#"# !) { / .)(!"#"#$%&’((!))$**+*(%!"#"# .5 #%&’(%); / *#’"*, ! #$8"#"#[! #$*#-*]; } 函数 $,’#*!"#"#(4,!)先计算出在循环的意义下队列 ! 的队尾元素在循环数组 8"#"# 中的 下一位置(*#-* 9 2)3 %-45.6#,然后在该位置处插入元素 "。实现方法如下: !" 数据结构与算法 !"#$ %&’()*+(+((*,’(- .,*+(+( *) { / #0(*+(+(1+22(*))%))")(%*+(+( #3 0+22%); / * #$)(4) "(* #$)(4) &5)6 * #$-4.3#7(; / * #$8+(+([* #$)(4)]".; } 函数 9(2(’(*+(+((*)先将队列 ! 的队首游标 0)"&’ 修改为在循环的意义下队首元素在循环 数组 8+(+( 中的下一位置(0)"&’ : 5)6 -4.3#7(,然后返回该位置处的元素,即队首元素。实现方 法如下: *,’(- 9(2(’(*+(+((*+(+( *) { / #0(*+(+(%-;’<(*))%))")(%*+(+( #3 (-;’<%); / * #$0)"&’ "(* #$0)"&’ &5)6 * #$-4.3#7(; / )(’+)& * #$8+(+([* #$0)"&’]; }; 以上用循环数组实现的队列基本运算都只要 "(5)的计算时间。 => =/ 应/ / 用 电路布线问题:印刷电路板将布线区域划分成 # ? $ 个方格阵列如图 => @4 所示。精确的电 路布线问题要求确定连接方格 % 的中点到方格 & 的中点的最短布线方案。在布线时,电路只能 沿直线或直角布线,如图 => @A 所示。为了避免线路相交,已布了线的方格做了封锁标记,其他线 路不允许穿过被封锁的方格。 图 => @/ 印刷电路板布线方格阵列 下面讨论抽象数据类型队列在解电路 布线问题中的应用。解电路布线问题时, 首先从起始位置 % 开始,将它作为第一个 考察方格,与该考察方格相邻并且可达的 方格成为待考察方格被加入到待考察方格 队列中,并且将这些方格标记为 5,即从起 始方格 % 到这些方格的距离为 5。接着,算 法从待考察方格队列中取出队首结点作为 下一个考察方格,并将与当前考察方格相 邻且未标记过的方格标记为 B,并存入待考察方格队列。这个过程一直继续到算法搜索到目标 !"第 = 章/ 队/ / 列 方格 ! 或待考察方格队列为空时为止。 在实现上述算法时,首先定义一个表示电路板上方格位置的结构 !"#$%$"&,它的两个成员 ’"( 和 )"* 分别表示方格所在的行和列。 %+,-.-/ #%’0)% ,&".- !!"#$%$"&; #%’0)% ,&".- {$&% ’"(,)"*;}!&".-; !"#$%$"& 1-(!"#$%$"&() { 2 2 !"#$%$"& ,; 2 2 $/((, "34**")(#$5-"/(!&".-))) ""6)7’’"’(%78940#%-. 3-3"’+: %); 2 2 -*#- ’-%0’& ,; } 函数 1-(!"#$%$"&()创建一个新方格位置结点。 在电路板的任何一个方格处,布线可沿右、下、左、上 ; 个方向进行。沿这 ; 个方向的移动分 别记为移动 6、<、=、>。在表 ;: < 中,"//#-%[$]#$’"( 和 "//#-%[$]#$)"*($ ? 6,<,=,>)分别给出沿这 ; 个方向前进 < 步相对于当前方格的相对位移。 表 !@ "# 移动方向的相对位移 移动 $ 方向 "//#-%[$]#$’() (**+,-[.]#$/(0 1 右 1 2 2 下 2 1 3 左 1 A 2 4 上 A 2 1 在实现上述算法时,用一个 3 维数组 5’.6 表示所给的方格阵列。初始时,5’.6[.][7]" 1,表 示该方格允许布线,而 5’.6[.][7]"2 表示该方格被封锁,不允许布线。为了便于处理方格边界 的情况,算法在所给方格阵列四周设置一道“围墙”,即增设标记为“2”的附加方格。算法开始时 测试初始方格与目标方格是否相同。如果这两个方格相同则不必计算,直接返回最短距离 1,否 则算法设置方格阵列的“围墙”,即初始化位移矩阵 (**+,-。算法将起始位置的距离标记为 3。由 于数字 1 和 2 用于表示方格的开放或封锁状态,所以在表示距离时不用这两个数字,因而将距离 的值都加 3。实际距离应为标记距离减 3。算法从起始位置 +-8’- 开始,标记所有标记距离为 4 的方格并存入待考察方格队列,然后依次标记所有标记距离为 9、:、⋯ 的方格,直至到达目标方 格 *.;.+< 或待考察方格队列为空时为止。具体算法可描述如下: .;- =.;6>8-(+.-.(; +-8’-@>(+.-.(; *.;.+<@>(+.-.(; !,4%9) { B !计算从起始位置 #%4’% 到目标位置 /$&$#9 的最短布线路径 2 2 找到最短布线路径则返回最短路长,否则返回 62 !! !" 数据结构与算法 ! "#$ ",%,&,’(#; ! )*+"$"*# ,(-(,#.-,*//+($[0]; ! 12(2( 1;! ! !!待考察方格队列 !! ! 1 "12(2(3#"$(); ! "/((+$4-$ #$-*5 ""/"#"+, #$-*5)66(+$4-$ #$7*’ ""/"#"+, #$7*’)) ! ! -($2-# 8;! !!+$4-$ "/"#"+, !! ! !!设置方格阵列“围墙”!! ! /*-(" "8;" A"9 &:;" &&) ! ! ! ;-"<[8]["]";-"<[# &:]["]":;! !!顶部和底部 !! ! /*-(" "8;" A"# &:;" &&) ! ! ! ;-"<["][8]";-"<["][9 &:]":;! !!左翼和右翼 !! ! !!初始化相对位移 !! ! /*-(" "8;" A0;" &&)*//+($["]"=(5)*+"$"*#(); ! *//+($[8]#$-*5 "8;*//+($[8]#$7*’ ":;! ! !!右 !! ! *//+($[:]#$-*5 ":;*//+($[:]#$7*’ "8;! ! !!下 !! ! *//+($[>]#$-*5 "8;*//+($[>]#$7*’ " #:;! !!左 !! ! *//+($[?]#$-*5 " @ :;*//+($[?]#$7*’ "8;! !!上 !! ! & "0;! !!相邻方格数 !! ! ,(-( "=(5)*+"$"*#(); ! ,(-( #$-*5 "+$4-$ #$-*5; ! ,(-( #$7*’ "+$4-$ #$7*’; ! ;-"<[+$4-$ #$-*5][+$4-$ #$7*’]">; ! !!标记可达方格位置 !! ! 5,"’((:){!!标记可达相邻方格 !! ! ! /*-(" "8;" A&;" &&){ ! ! ! #.- "=(5)*+"$"*#(); ! ! ! #.- #$-*5 ",(-( #$-*5 &*//+($["]#$-*5; ! ! ! #.- #$7*’ ",(-( #$7*’ &*//+($["]#$7*’; ! ! ! "/(;-"<[#.- #$-*5][#.- #$7*’] ""8){ ! ! ! ! !!该方格未标记 !! ! ! ! ! ;-"<[#.- #$-*5][#.- #$7*’]";-"<[,(-( #$-*5][,(-( #$7*’]&:; ! ! ! ! "/((#.- #$-*5 ""/"#"+, #$-*5)66(#.- #$7*’ ""/"#"+, #$7*’)).-(4&;!!完成布线 !! ! ! ! ! A#$(-12(2((#.-,1);} ! ! ! } ! ! !!是否到达目标位置 /"#"+,?!! ! ! "/((#.- #$-*5 ""/"#"+, #$-*5)66(#.- #$7*’ ""/"#"+, #$7*’)).-(4&;!!完成布线 !! ! ! !!待考察方格队列是否非空 !! ! ! "/(12(2(A9B$C(1))-($2-# 8;!!无解 !! !"第 0 章! 队! ! 列 ! ! "#$# "%#&#’#()#)#(();!!取下一个考察方格 !! ! ! } ! !!构造最短布线路径 !! ! &#* "+$,-[.,*,/" #$$01][.,*,/" #$20&]#3; ! .0$(, "4;, A&#*;, &&)56’"[,]"7#180/,’,0*(); ! !!从目标位置 .,*,/" 开始向起始位置回溯 !! ! "#$# ".,*,/"; ! .0$(9 "&#* #:;9 $"4;9 ##){ ! ! ! 56’"[9]""#$#; ! ! ! !!找前驱位置 !! ! ! ! .0$(, "4;, A;;, &&){ ! ! ! ! *<$ "7#180/,’,0*(); ! ! ! ! *<$ #$$01 ""#$# #$$01 &0../#’[,]#$$01; ! ! ! ! *<$ #$20& ""#$# #$20& &0../#’[,]#$20&; ! ! ! ! ,.(+$,-[*<$ #$$01][*<$ #$20&] ""9 &3)<$#6;; ! ! ! ! } ! ! ! "#$# "*<$;! !!向前移动 !! ! ! ! } ! $#’)$* &#*; } 图 => :4 是在一个 ? @ ? 方格阵列中布线的例子。其中起始位置是 ! A(B,3),目标位置是 " A(=,C),阴影方格表示被封锁的方格。当算法搜索到目标方格 " 时,将目标方格 " 标记为从起 始位置 ! 到 " 的最短距离。在上例中,! 到 " 的最短距离是 D。要构造出与最短距离相应的最短 路径,可以从目标方格开始向起始方格方向回溯,逐步构造出最优解。每次向标记的距离比当前 方格标记距离少 : 的相邻方格移动,直至到达起始方格为止。在标记有如图 => :46 所示最短距 离的方格中,从目标方格 " 移到(E,C),然后移至(C,C),⋯ ,最终移至起始方格 !,得到的相应最 短路径如图 => :4< 所示。 图 => :4! 布线算法示例 !" 数据结构与算法 由于每个方格成为待考察方格进入待考察方格队列最多 ! 次,因此,待考察方格队列中最多 只处理 !("#)个待考察方格。考察每个方格需 !(!)时间,因此算法共耗时 !("#)。构造相应 的最短距离需要时间 !($),其中 $ 是最短布线路径的长度。 本 章 小 结 本章介绍了抽象数据类型队列的基本概念及其逻辑特征。按照抽象数据类型设计和实现的 一般性原则,详细介绍了实践中常用的用指针实现队列的方法和用循环数组实现队列的方法,最 后以电路布线问题为例讨论了队列的应用方法。本章介绍的抽象数据类型队列在后续各章中还 会反复用到。 习" " 题 !" #$ 扩充抽象数据类型队列的定义,增加如下队列运算: (!)#$%$%&’(%(#):确定队列 % 的大小。 ())#$%$%*+(#):输入一个队列 %。 (,)#$%$%-$.(#):输出队列 %。 用指针实现上述扩充后的抽象数据类型队列。 !" %$ 扩充抽象数据类型队列的定义,增加如下队列运算: (!)#$%$%&/0’.(&!,&)):将队列 &! 分为大小相同的 ) 个队列 &! 和 &)。队列 &) 中含原队列 &! 中第 !,,,1, ⋯ 等元素,其余元素留在队列 &! 中。 ())#$%$%2345’+%(&!,&)):将队列 &) 中元素交错地合并于队列 &! 中,且保持原队列中元素的相对次序。 &) 成为空队列。 用指针实现上述扩充后的抽象数据类型队列。 !" &$ 扩充抽象数据类型队列的定义,增加如下队列运算: -+#$%$%(6,#):若 ’ 是队列中的一个元素,则函数返回 !,否则返回 7。用指针实现上述扩充后的抽象数据 类型队列。 !" !$ 如何实现以任意长的字符串为元素的队列,将一个字符串入队的运算耗时如何? !" ’$ 实现队列的另一种链表结构,使用一个指向队首结点的哨兵结点来简化出队运算。试用这种表示法 实现队列的各种基本运算,并分析这种表示法的优缺点。 !" ($ 写出用循环数组表示的队列长度的计算公式。 !" )$ 用循环数组实现的队列重做习题 89 !。 !" *$ 用循环数组实现的队列重做习题 89 )。 !" +$ 用循环数组实现的队列重做习题 89 ,。 !" #,$ 如果用一个布尔量来表示循环数组中的队列是否为空队列,那么应当如何定义这种队列结构的类 型?写出在这种表示法下实现队列的基本运算。 !" ##$ 用循环数组表示队列的另一种方法是用一个游标指示队首元素所在的单元,并用一个整数表示队列 长度,问: (!)如果用这种方法来实现队列,是否有必要限制队列的长度? ())写出在这种表示法下,实现队列的 : 个基本运算。 !"第 8 章" 队" " 列 (!)与本章介绍的用循环数组表示的队列进行比较。 !" #$% 区分用循环数组实现的满队列和空队列的一个方法是在 "#$#$ 结构中增加一个变量 %&’()* 来记录 最近一次执行的队列运算。如果 %&’()* 记录的最近一次执行的队列运算是 +,($-"#$#$,则可断定当前队列一定 非空;如果 %&’()* 记录的最近一次执行的队列运算是 .$/$($"#$#$,则可断定当前队列一定不满。因此,当 0-1,( 2 -$&- 时,可借助 %&’()* 来区分满队列和空队列。 试用上述思想修改用循环数组实现的队列。 !" #&% 双向队列是一种特殊的表,对这种表进行插入或删除操作都只能在表的任意一端进行。试用数组、 指针和游标这 ! 种不同结构实现双向队列。 !" #!% 在数字化图像处理中常将一幅图像表示为一个 ! 3 ! 的像素矩阵。其中每个像素的值为 4 或 5。值 为 4 的像素表示图像的背景,而值为 5 的像素表示图像中某个图元上的一个点,通常称其为图元像素。当一个 像素在另一个像素的上方、下方、左侧或右侧时,称这两个像素为相邻像素。一幅图像中的相邻像素属于同一图 元,而不相邻的像素属于不同图元。 图元识别问题就是对给定图像的图元像素进行标记,使得同一图元的图元像素有相同的标记,而不同图元 的图元像素其标记也不同。试用抽象数据类型队列设计图元识别问题的算法,并分析算法的计算复杂性。 !" 数据结构与算法 书书书 第 ! 章" 递" " 归 学习目标 ! 理解递归的概念。 ! 掌握用分治法设计有效算法的策略。 ! 掌握用动态规划方法设计有效算法的策略。 ! 掌握用回溯法解题的算法设计策略。 ! 理解递归算法的工作原理和模拟递归的方法。 !" #$ 递归的概念 直接或间接地调用自身的算法称为递归算法。用函数自身给出定义的函数称为递归函数。 在数据结构与算法设计中,递归技术是十分有用的。使用递归技术往往使函数的定义和算法的 描述简洁且易于理解。有些数据结构如二叉树等,由于其本身固有的递归特性,特别适合用递归 的形式来描述。另外,还有一些问题,虽然其本身并没有明显的递归结构,但用递归技术来求解 会使设计出的算法简洁易懂且易于分析。 下面用实例说明递归的概念及其应用范围。 (#)阶乘函数 阶乘函数可递归地定义为: !! " # !(! # #)!$ $ ! " % ! %{ % 阶乘函数的自变量 ! 的定义域是非负整数,递归式的第一式给出了这个函数的初始值,是非 递归地定义的。每个递归函数都必须有非递归定义的初始值,否则,递归函数就无法计算。递归 式的第二式是用较小自变量的函数值来表达较大自变量的函数值的方式来定义 ! 的阶乘。定义 式的左右两边都引用了阶乘记号,它是递归定义式,写成递归算法如下: &’( )*+(,-&*.(&’( ’) { ! ! "#($ !!%)&’()&$ *; ! ! &’()&$ $!#+,(-&"+.($ "*); } (/)0"1-$+,," 数列 无穷数列 *,*,/,2,3,4,*2,/*,25,33,⋯ 称为 0"1-$+,," 数列。它可以递归地定义为: !(")# $ $ $ $ *! ! ! ! ! ! " # % * " # * !(" % *)& !(" % /) " ’ { * 这是一个递归关系式,它说明当 " 大于 * 时,这个数列的第 " 项的值是它前面两项之和。它 用 / 个较小的自变量的函数值来定义较大自变量的函数值,所以需要 / 个初始值 !(%)和 !(*)。 第 " 个 0"1-$+,," 数可递归地描述如下: "$( #"1-$+,,"("$( $) { ! "#($ #!*)&’()&$ *; ! &’()&$ #"1-$+,,"($ "*)$#"1-$+,,"($ "/); } 上述两例中的函数也可用如下非递归方式定义 "! # *· /· 2· ⋯ ·(" % *)· " !(")# * !3 !* 6 3( )/ " & * % !* 7 3( )/ " &( )* (2)8,9’&:+$ 函数 并非一切递归函数都能用非递归方式定义。为了对递归函数的复杂性有更多的了解,再介 绍一个双递归函数——— 8,9’&:+$ 函数。当一个函数及它的一个变量是由函数自身定义时,称这 个函数是双递归函数。8,9’&:+$ 函数 ((",))有两个独立的整变量 )"% 和 ""%,其定义如下: ((*,%)# / ((%,))# * )"% ((",%)# " & / ""/ ((",))# ((((" % *,)),) % *) ",)"      * ((",))的自变量 ) 的每一个值都定义了一个单变量函数。例如,递归式的第三式表示 ) ; % 定义了函数“加 /”。) ; * 时,由于 ((*,*); ((((%,*),%); ((*,%); / 以及 ((",*)# ( (((" 7 *,*),%); ((" 7 *,*)6 /,其中(" < *),因此 ((",*); /",其中(""*),即 ((",*)是 函数“乘 /”。 当 ) ; / 时,((",/); ((((" 7 *,/),*); /((" 7 *,/)和 ((*,/); ((((%,/),*); ((*, *); /,故 ((",/)# /" 。 类似地可以推出,((",2)# //#/ ,其中指数中 / 的层数为 "。 !" 数据结构与算法 !(",!)的增长速度非常快,以至于没有适当的数学式子来表示这一函数。 单变量的 "#$%&’() 函数 !(")定义为,!(")* !(",")。其逆函数 !(")在算法复杂性分析 中常遇到。它定义为:!(")* ’+){#, !(#)""}。即 !(")是使 "$!(#)成立的最小的 # 值。 例如,由 !(-)* .,!(.)* /,!(/)* ! 和 !(0)* .1 推知,!(.)* -,!(/)* .,!(0)* !(!) * / 以及 !(2)* ⋯ * !(.1)* 0。可以看出 !(")的增长速度非常慢。 !(!)* //#/ (其中指数中 / 的层数为 12 201)。这个数非常大,无法用通常的方式来表达 它。如果要写出这个数将需要 345(!(!))位,即 //#/ (12 202 层 / 的方幂)那么多位。所以,对于 通常所见到的正整数 ",有 !(")$!。但在理论上 !(")没有上界,随着 " 的增加,它以难以想象 的慢速度趋向正无穷大。 (!)排列问题 设 $ *{%. ,%/ ,⋯ ,%" }是要进行排列的 " 个元素,$& ’ $ ({%& }。集合 ) 中元素的全排列记为 6%&’(7)。(&+ )6%&’(7)表示在全排列 6%&’(7)中的每一个排列前加上前缀 %& 得到的排列。$ 的全排列可归纳定义如下: 当 " * . 时,6%&’(8)*(& ),其中 % 是集合 $ 中惟一的元素; 当 " 9 . 时,6%&’(8)由(&. )6%&’(8. ),(&/ )6%&’(8/ ),⋯ ,(&) )6%&’(8) )构成。 依此递归定义,可设计产生全排列的递归算法如下: :4+; 6%&’(+)< 3+=<[],+)< $,+)< ’) {> %!产生 3+=<[$:’]的所有排列 !% > > > +)< +; > > > +?($ !!’) > > > {> %!单元素序列 !% > > > > ?4&(+ !-;+ #!’;+ $$)6&+) > > > 6&+) > > } > > > %3=% > > > > %!多元素序列,递归产生排列 !% > > > > ?4&(+ !$;+ #!’;+ $$) > > > > { > > > > > =A(6(3+=<[$],3+=<[+]); > > > > > 6%&’(3+=<,$ $.,’); > > > > > =A(6(3+=<[$],3+=<[+]); > > > > } } 算法 6%&’(3+=<,$,’)递归地产生所有前缀为 3+=<[-:$ B .]且后缀为 3+=<[$:’]的全排列的所 有排列。调用算法 6%&’(3+=<,-,) B .)则产生 3+=<[-:) B .]的全排列。 !"第 2 章> 递 > > 归 在一般情况下,! ! ",算法将 "#$%[&:’]中每一个元素分别与 "#$%[&]中元素交换,然后递归 地计算 "#$%[& ( ):’]的全排列,并将计算结果作为 "#$%[*:&]的后缀。算法中的函数 $+,- 用于交 换两个表元素值。 (.)整数划分问题 将正整数 # 表示成一系列正整数之和,# $ #) % #/ % ⋯ % #! ,其中,#) "#/ "⋯ "#! "),!")。 正整数 # 的这种表示称为正整数 # 的划分。正整数 # 的不同的划分个数称为正整数 # 的划 分数,记作 &(#)。 例如,正整数 0 有如下 )) 种不同的划分,所以 &(0)1 ))。 2 2 0 2 2 . ( ) 2 2 3 ( /,3 ( ) ( ) 2 2 4 ( 4,4 ( / ( ),4 ( ) ( ) ( ) 2 2 / ( / ( /,/ ( / ( ) ( ),/ ( ) ( ) ( ) ( ) 2 2 ) ( ) ( ) ( ) ( ) ( ) 在正整数 # 的所有不同的划分中,将最大加数 #) 不大于 " 的划分个数记作 ’(#,")。可以 建立 ’(#,")的如下递归关系。 ! ’(#,))1 )2 2 #") 当最大加数 #) 不大于 ) 时,任何正整数 # 只有一种划分形式,即 # $ # ) ( ) ( ⋯     ( ) 。 " ’(#,")1 ’(#,#)""# 最大加数 #) 实际上不能大于 #。因此,’(),")1 )。 # ’(#,#)1 ) ( ’(#,# 5 )) 正整数 # 的划分由 #) $ # 的划分和 #) $# ( ) 的划分组成。 $ ’(#,")1 ’(#," 5 ))( ’(# ( ","),# 6 " 6 ) 正整数 # 的最大加数 #) 不大于 " 的划分,由 #) $ " 的划分和 #) $" ( ) 的划分组成。 以上关系实际上给出了计算 ’(#,")的递归式如下。 ’(#,")$ ) # $ )," $ ) ’(#,#) # ) " ) ( ’(#,# ( )) # $ " ’(#," ( ))% ’(# ( ",") # * " *        ) 据此,可设计计算 ’(#,")的递归算法如下。正整数 # 的划分数 &(#)1 ’(#,#)。 #7% 8(#7% 7,#7% ’) !" 数据结构与算法 { ! "#(($ #%)(((& #%))’()*’$ +; ! "#(($ !!%)(((& !!%))’()*’$ %; ! "#($ #&)’()*’$ ,($,$); ! "#($ !!&)’()*’$ ,($,& "%)$%; ! ’()*’$ ,($,& "%)$,($ "&,&); } (-)链表结构 第 . 章介绍的用指针实现的链表结构本质上是递归定义的。因此许多关于链表结构的运算 可以用递归方法实现。例如,计算链表长度的函数 /"0)/($1)2%(/)可递归实现如下: "$) /"0)/($1)2%(/"0) /) { ! ’()*’$ 34*$)(/ ")#"’0)); } "$) 34*$)(5"$6 7) { ! ! "#(7 !!+)’()*’$ +; ! ! ’()*’$ % $34*$)(7 ")$(7)); } 从表首到表尾遍历链表的算法也可以递归实现如下: 84"9 )’:8(’0((5"$6 2,84"9(!8"0"))(5"$6)) { ! ! "#(2 !!+)’()*’$; ! ! (!8"0"))(2); ! ! )’:8(’0((2 ")$(7),8"0")); } 函数 )’:8(’0(; 从表尾到表首遍历链表用递归方法实现如下: 84"9 )’:8(’0(;(5"$6 2,84"9(!8"0"))(5"$6)) ! { ! ! "#(2 !!+)’()*’$; ! ! )’:8(’0(;(2 ")$(7),8"0")); !"第 < 章! 递 ! ! 归 ! ! (!"#$#%)(&); ! } 函数 ’()(%(#%(*(+,,)从链表中删除元素 ! 用递归方法实现如下: )#-. ’()(%(#%(*()#-. +,/#$%0%(* ,) { ! ! #1(+ !!2)3(%43- 2; ! ! #1(+ ")()(*(-% !!,) ! ! ! {)#-. % !+ ")-(,%;13(((+);3(%43- %;} ! ! + ")-(,% !’()(%(#%(*(+ ")-(,%,,); ! ! 3(%43- +; } (5)间接递归 前面介绍的递归函数都是直接调用其自身,这类递归函数称为直接递归函数。间接递归函 数通过调用别的函数间接地调用其自身。 例如,计算正弦和余弦函数的递归式为 $#-6! 7 6$#-!89$! 89$6! 7 : ; 6($#-!)6 当 ! 充分小时,可以用 <=>)93 展开式计算 $#-! " ! # !? $ @ 89$! " : ; !6 $ 6 由此可以设计计算 $#-(!)和 89$(!)的间接递归函数如下: ’94A)( $(’94A)( ,) { ! ! #1( "2B 22C #, DD , #2B 22C)3(%43- , ",!,!,%@; ! ! 3(%43- 6!$(,%6)!8(,%6); } ’94A)( 8(’94A)( ,) { ! ! #1( "2B 22C #, DD , #2B 22C)3(%43- :B 2 ",!,%6; ! ! 3(%43- :B 2 "6!$(,%6)!$(,%6); } !" 数据结构与算法 !" #$ 递归程序设计 !" #" $ % 分治与递归 任何可以用计算机求解的问题所需的计算时间都与其规模有关。问题的规模越小,解题所 需的计算时间往往也越少,从而也较容易处理。例如,对于 ! 个元素的排序问题,当 ! % & 时,不 需任何计算。! % # 时,只要作一次比较即可排好序。! % ’ 时只要作 # 次比较即可⋯ ⋯ 而当 ! 较 大时,问题就不那么容易处理了。要想直接解决一个较大的问题,有时是相当困难的。分治法的 设计思想是,将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分 而治之。如果原问题可分割成 " 个子问题,& ( "$!,且这些子问题都可解,并可利用这些子问题 的解求出原问题的解,那么这种分治法就是可行的。由分治法产生的子问题往往是原问题的较 小模式,这就为使用递归技术提供了方便。在这种情况下,反复应用分治手段,可以使子问题与 原问题类型一致而其规模却不断缩小,最终使子问题缩小到很容易求出其解,由此自然导致递归 算法。分治与递归像一对孪生兄弟,经常同时应用在算法设计之中,并由此产生许多高效算法。 下面用例子说明如何针对具体问题用分治思想来设计有效算法。 给定数组 #,求 )[*:+]中最大元素的分治法是将 )[*:+]分为大小相同的两段——— )[*:,]和 )[, - &:+]。递归地找到这两段的最大元素,这两个元素的较大者即为 )[*:,]的最大元素。 .*/,/01 ,)23,4,(.*/,/01 )[],301 *,301 +) { $ $ .*/,/01 4,5;301 , !(* $+)%#; $ $ 36(* !!+)+/14+0 )[*]; $ $ 4 !,)23,4,(),*,,); $ $ 5 !,)23,4,(),, $&,+); $ $ 36(4 )5)+/14+0 4; $ $ /*7/ +/14+0 5; } 二分搜索算法是运用分治策略的典型例子。 给定已排好序的 ! 个元素 )[8:0 9 &],现要在这 ! 个元素中找出一特定元素 $。 首先较易想到的是用顺序搜索法,逐个比较 )[8:0 9 &]中元素,直至找出元素 $ 或搜索遍整 个数组后确定 $ 不在其中。这个方法没有很好地利用 ! 个元素已排好序这个条件,因此在最坏 情况下,顺序搜索方法需要 %(!)次比较。 二分搜索方法充分利用了元素间的次序关系,采用分治策略,可在最坏情况下用 %(*:;!)时 间完成搜索任务。 二分搜索算法的基本思想是将 ! 个元素分成个数大致相同的两段,取 )[0 < #]与 $ 作比较。 !"第 ! 章$ 递 $ $ 归 如果 ! " #[$ % &],则找到 !,算法终止。如果 ! ’ #[$ % &],则只要在数组 " 的左半部继续搜索 !。 如果 ! ( #[$ % &],则只要在数组 " 的右半部继续搜索 !。具体算法可描述如下: )$* +,-#+./()$* #[],)$* !,)$* 0,)$* +) {%!在 #[0:+]中搜索 !,找到时返回 ! 的位置,否则返回 "1 !% 2 2 )$* 3 !(0 $+)%&; 2 2 )4(0 )+)+-*5+$ "1; 2 2 )4(! !!#[3])+-*5+$ 3; 2 2 )4(! ##[3])+-*5+$ +,-#+./(#,!,0,3 "1); 2 2 -0,- +-*5+$ +,-#+./(#,!,3 $1,+); } 容易看出,每执行一次递归调用,待搜索数组的大小减少一半。因此,在最坏情况下,执行了 #(067$)次递归调用,除了递归调用的计算量,算法耗费时间 #(1),因此整个算法在最坏情况下 的计算时间复杂性为 #(067$)。 !" #" #$ 动态规划 动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子 问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划法求解的 问题,经分解得到的子问题往往不是互相独立的。若用分治法解这类问题,则分解得到的子问题 数目太多,以至于最后解决原问题需要耗费时间为指数量级。然而,不同子问题的数目常常只有 多项式量级。在用分治法求解时,有些子问题被重复计算了许多次。如果能够保存已解决子问 题的答案,而在需要时再找出已求得的答案,就可以避免大量重复计算,从而得到多项式量级的 算法。为了达到这个目的,可以用一个表来记录所有已解决的子问题的答案。不管该子问题以 后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思想。具体的 动态规划算法是多种多样的,但它们具有相同的填表格式。 下面用矩阵连乘积问题说明动态规划算法的基本思想。 给定 $ 个矩阵{!1 ,!& ,⋯ ,!$ },其中 !% 与 !% & 1 是可乘的,% " 1,&,⋯ ,$ 8 1。考察这 $ 个矩阵 的连乘积 !1 !& ⋯ !$ 。由于矩阵乘法满足结合律,故计算矩阵的连乘积可以有许多不同的计算次 序。这种计算次序可以用加括号的方式来确定。若一个矩阵连乘积的计算次序完全确定,也就 是说该连乘积已完全加括号,则可以依此次序反复调用两个矩阵相乘的标准算法计算出矩阵连 乘积。完全加括号的矩阵连乘积可递归地定义为: ! 单个矩阵是完全加括号的; " 矩阵连乘积 ! 是完全加括号的,则 ! 可表示为两个完全加括号的矩阵连乘积 " 和 # 的 乘积并加括号,即 ! "("#)。 例如,矩阵连乘积 !1 !& !9 !: 可以有以下 ; 种不同的完全加括号方式: (!1(!&(!9 !: ))) !" 数据结构与算法 (!!((!" !# )!$ )) ((!! !" )(!# !$ )) ((!!(!" !# ))!$ ) (((!! !" )!# )!$ ) 每一种完全加括号方式对应于一个矩阵连乘积的计算次序,而矩阵连乘积的计算次序与其 计算量有密切关系。 首先考虑计算两个矩阵乘积所需的计算量。 计算两个矩阵乘积的标准算法如下,其中,%&、’& 和 %(、’( 分别表示矩阵 " 和 # 的行数和列 数。 )*+, -&.%+/012.+324(+5. !!&,+5. !!(,+5. !!’,+5. %&,+5. ’&,+5. %(,+5. ’() { 6 +5. +,7,8; 6 +9(’&!!%():%%*%(&矩阵不可乘&); 6 6 9*%(+ !;;+ #%&;+ $$) 6 6 6 6 9*%(7 !;;7 #’(;7 $$){ 6 6 6 6 6 +5. <1- !&[+][;]!([;][7]; 6 6 6 6 6 9*%(8 !!;8 #’&;8 $$)<1- $ !&[+][8]!([8][7]; 6 6 6 6 6 ’[+][7]!<1-; 6 6 6 6 6 } } 矩阵 " 和 # 可乘的条件是矩阵 " 的列数等于矩阵 # 的行数。若 " 是一个 ! " # 矩阵,# 是一 个 # " $ 矩阵,则其乘积 $ % " " # 是一个 ! " $ 矩阵。在上述计算 $ 的标准算法中,主要计算量在 # 重循环,总共需要 !#$ 次数乘。 为了说明在计算矩阵连乘积时,加括号方式对整个计算量的影响,考察计算 # 个矩阵{!! , !" ,!# }连乘积的例子。设这 # 个矩阵的维数分别为 !; = !;;、!;; = > 和 > = >;。若按加括号方 式((!! !" )!# )计算,# 个矩阵连乘积需要的数乘次数为 !; = !;; = > ? !; = > = >; @ A >;;。若按 加括号方式(!!(!" !# ))计算,# 个矩阵连乘积总共需要 !;; = > = >; ? !; = !;; = >; @ A> ;;; 次 数乘。第二种加括号方式的计算量是第一种加括号方式计算量的 !; 倍。由此可见,在计算矩阵 连乘积时,加括号方式,即计算次序对计算量有很大影响。于是,自然提出矩阵连乘积的最优计 算次序问题,即对于给定的相继 & 个矩阵{!! ,!" ,⋯ ,!% }(其中矩阵 !’ 的维数为 !’ ( ! " !’ ,’ @ !, ",⋯ ,&),如何确定计算矩阵连乘积 !! ,!" ⋯ !& 的计算次序(完全加括号方式),使得依此次序计 算矩阵连乘积需要的数乘次数最少。 穷举搜索法是最容易想到的方法,也就是列举出所有可能的计算次序,并计算出每一种计算 次序相应需要的数乘次数,从中找出一种数乘次数最少的计算次序。这样做计算量太大。事实 上,对于 & 个矩阵的连乘积,设其不同的计算次序为 )(&)。由于可以先在第 * 个和第 * + ! 个矩 阵之间将原矩阵序列分为两个矩阵子序列,* % !,",⋯ ,& ( !;然后分别对这两个矩阵子序列完全 !"第 > 章6 递 6 6 归 加括号;最后对所得的结果加括号,得到原矩阵序列的一种完全加括号方式。由此,可以得到关 于 !(")的递推式如下 !(")# ! " # ! % "$! % # ! !(%)!(" $ %) " & { ! " " 解此递归方程可得,!(")实际上是 #$%$&$’ 数,即 !(")# ’(" $ !),其中 ’(")# ! " ( ! ("( )" # !()" ) "* ) ( ) 也就是说,!(")是随 " 的增长呈指数增长的。因此,穷举搜索法不是一个有效算法。 设计求解具体问题的动态规划算法的第一步是刻画该问题的最优解结构特征。首先,为方 便起见,将矩阵连乘积 !* !* ( ! ⋯ !+ 简记为 +[,:-]。考察计算 +[!:’]的最优计算次序。设这个 计算次序在矩阵 !% 和 !% ( ! 之间将矩阵链断开,! $ % , ",则其相应的完全加括号方式为 ((!! ⋯ !% )(!% ( ! ⋯ !" ))。即依此次序,先计算 +[!:.]和 +[. / !:’],然后将计算结果相乘得 到 +[!:’]。依此计算次序,总计算量为 +[!:.]的计算量加上 +[. / !:’]的计算量,再加上 +[!:.]和 +[. / !:’]相乘的计算量。 这个问题的一个关键特征是:计算 +[!:’]的最优次序所包含的计算矩阵子链 +[!:.]和 +[. / !:’]的次序也是最优的。事实上,若有一个计算 +[!:.]的次序需要的计算量更少,则用 此次序替换原来计算 +[!:.]的次序,得到的计算 +[!:’]的计算量将比按最优次序计算所需计 算量更少,这是一个矛盾。同理可知,计算 +[!:’]的最优次序所包含的计算矩阵子链 +[. / !: ’]的次序也是最优的。 因此,矩阵连乘积计算次序问题的最优解包含着其子问题的最优解。这种性质称为最优子 结构性质。问题的最优子结构性质是该问题可用动态规划算法求解的显著特征。 设计动态规划算法的第 ( 步是递归地定义最优值。对于矩阵连乘积的最优计算次序问题, 设计算 +[,:-],!$*$+$",所需的最少数乘次数为 0[,][-],则原问题的最优值为 0[!][’]。 当 * # + 时,+[,:-]1 +, 为单一矩阵,无需计算,因此 0[,][,]1 2,* # !,(,⋯ ,"。 当 * , + 时,可利用最优子结构性质计算 0[,][-]。事实上,若计算 +[,:-]的最优次序在 !% 和 !% ( ! 之间断开,*$% 3 +,则 0[,][-]1 0[,][.]/ 0[. / !][-]/ 4, 5 ! 6 4. 6 !- 。由于在计算时 并不知道断开点 % 的位置,所以 % 还未定。不过 % 的位置只有 + $ * 种可能,即 %&{*,* ( !,⋯ ,+ $ !}。因此,% 是这 + $ * 个位置中使计算量达到最小的那个位置。从而 0[,][-]可以递归地定 义为 0[,][-]1 2 0,’ ,$. 3 - {0[,][.]/ 0[. / !][-]/ 4, 5 ! 4. 4- }" , 1 -{ , 3 - 0[,][-]给出了最优值,即计算 +[,:-]所需的最少数乘次数。同时还确定了计算 +[,:-]的 最优次序中的断开位置 %,也就是说,对于这个 % 有 0[,][-]1 0[,][.]/ 0[. / !][-]/ 4, 5 ! 6 4. 6 4- 若将对应于 0[ ,][ -]的断开位置 % 记为 7[ ,][ -],在计算出最优值 0[ ,][ -]后,可递归 !" 数据结构与算法 地由 ![ "][ #]构造出相应的最优解。 根据上述递归式可以设计计算 $["][#]值的递归算法 %&’(%)*+%",-.*"/(",#)如下: "/+ %&’(%)*+%",-.*"/("/+ ","/+ #) { 0 0 0 "/+ 1,(; 0 0 0 "2(" !!#)%&+(%/ 3; 0 0 0 ( !%&’(%)*+%",-.*"/(" $4,#)$5[" "4]!5["]!5[#]; 0 0 0 !["][#]!"; 0 0 0 26%(1 !" $4;1 ##;1 $$){ 0 0 0 0 "/+ + !%&’(%)*+%",-.*"/(",1)$%&’(%)*+%",-.*"/(1 $4,#)$5[" "4]!5[1]!5[#]; 0 0 0 0 "2(+ #(){ 0 0 0 0 0 ( !+; 0 0 0 0 0 !["][#]!1;} 0 0 0 0 0 } 0 0 0 %&+(%/ (; } %&’(%)*+%",-.*"/(4,/)给出计算 7[4:/]需要的最少数乘次数。按什么次序做矩阵乘法才 能达到这个最少的数乘次数?事实上,%&’(%)*+%",-.*"/ 已记录了构造最优解所需要的全部信 息。!["][#]中的数 1 表明计算矩阵链 7[":#]的最佳方式应在矩阵 !! 和 !! " 4 之间断开,即最优 的加括号方式应为(7[":1])(7[1 8 4:#])。因此,从 ![4][/]记录的信息可知计算 7[4:/]的 最优加括号方式为(7[4:![4][/]])(7[![4][/]8 4:/])。而 7[4:![4][/]]的最优加括号方 式为(7[4:![4][![4][/]]])(7[![4][![4][/]]8 4:![4][![4][/]]])。同理可以确定 7 [![4][/]8 4:/]的最优加括号方式在 ![![4][/]8 4][/]处断开⋯ ⋯ 照此递推下去,最终可以 确定 7[4:/]的最优完全加括号方式,即构造出问题的一个最优解。 下面的算法 +%*’&9*’1 按算法 %&’(%)*+%",-.*"/ 计算出的断点矩阵 " 指示的加括号方式输出 计算 7[":#]的最优计算次序。 :6"; +%*’&9*’1("/+ !!!,"/+ ","/+ #) { 0 0 "2(" !!#)%&+(%/; 0 0 0 +%*’&9*’1(!,",!["][#]); 0 0 0 +%*’&9*’1(!,!["][#]$4,#); 0 0 0 5%"/+2(&)(<+"5<= 7(> ;,> ;)*/; 7(> ;,> ;)?/&,",!["][#],(!["][#]$4),#); } 要输出 7[4:/]的最优计算次序只要调用 +%*’&9*’1(!,4,/)即可。 分析上面的算法 %&’(%)*+%",-.*"/ 可以看到,在用递归算法自顶向下解此问题时,每次产生 !"第 @ 章0 递 0 0 归 的子问题并不总是新问题,有些子问题被反复计算多次。例如用算法 !"#$!%&’!()*+&(,(-,.)计 算 /[-:.]的递归树如图 01 - 所示。从该图可以看出,许多子问题被重复计算。 图 01 -2 计算 /[-3 .]的递归树 事实上,可以证明该算法的计算时间 !(")有指数下界。设算法中判断语句和赋值语句花费 常数时间,则由算法的递归部分可得关于 !(")的递归不等式如下 !(")" # # # # $(-)# # # # # # # # " % - - & % "’- ( % - (!(()& !(" ’ ()& -)# " ) { - 2 2 因此,当 " ) - 时,!(")"- 4(" ’ -)& % "’- ( % - !(()& % "’- ( % - !(" ’ ()% " & 5 % "’- ( % - !(() 据此,可用数学归纳法证明 !(")"5" ’ - % !(5" ) 因此,直接递归算法 !"#$!6&’!()*+&(, 的计算时间随 " 指数增长。 注意,在递归计算过程中,不同的子问题个数只有 "("5 )个。事实上,对于 -$*$+$" 不同 的有序对(*,+)对应于不同的子问题。因此,不同子问题的个数最多只有 "( )5 & " % "("5 )个。 用动态规划算法解此问题的基本思想是,依据递归式以自底向上的方式进行计算。在计算 过程中,保存已解决的子问题答案。每个子问题只计算一次,而在后面需要时只要简单查一下, 从而避免大量的重复计算,最终得到多项式时间的算法。下面所给出的动态规划算法 6&’!()7 *+&(, 中,输入参数{,8 ,,- ,⋯ ,," }存储于数组 , 中。算法除了输出最优值数组 - 外还输出记录 最优断开位置的数组 .。 (,’ 6&’!()*+&(,((,’ !9,(,’ !!6,(,’ !!:,(,’ ,) { 2 2 2 (,’ (,;,!; 2 2 2 <=!(( !-;( #!,;( $$)6[(][(]!8; 2 2 2 <=!(! !5;! #!,;! $$) 2 2 2 2 <=!(( !-;( #!, "! $-;( $$){ 2 2 2 2 2 2 (,’ > !( $! "-; 2 2 2 2 2 2 6[(][>]!6[( $-][>]$9[( "-]!9[(]!9[>]; 2 2 2 2 2 2 :[(][>]!(; 2 2 2 2 2 2 <=!(; !( $-;; #>;; $$){ !! 数据结构与算法 ! ! ! ! ! ! ! "#$ $ !%["][&]$%[& $’][(]$)[" "’]!)[&]!)[(]; ! ! ! ! ! ! ! "*($ #%["][(]){ ! ! ! ! ! ! ! ! %["][(]!$; ! ! ! ! ! ! ! ! +["][(] !&;} ! ! ! ! ! ! ! } ! ! ! ! ! ! } ! ! ! ,-$.,# %[’][#]; } 算法 %/$,"012/"# 首先计算 %["]["]3 4,! " ’,5,⋯ ,#。然后,根据递归式,按矩阵链长度递 增的方式依次计算 %["][" 6 ’],! " ’,5,⋯ ,# $ ’,(矩阵链长度为 5);%["][" 6 5],! " ’,5,⋯ ,# $ 5,(矩阵链长度为 7);⋯ ⋯ 。在计算 %["][(]时,只用到已计算出的 %["][&]和 %[& 6 ’][(]。 例如,设要计算矩阵连乘积 !’ !5 !7 !8 !9 !: ,其中各矩阵的维数分别为: !’ !5 !7 !8 !9 !: 74 ; 79 79 ; ’9 ’9 ; 9 9 ; ’4 ’4 ; 54 54 ; 59 动态规划算法 %/$,"012/"# 计算 %["][(]先后次序如图 9< 5/ 所示;计算结果 %["][(]和 +["] [(],’$!$%$# 的过程分别如图 9< 5= 和图 9< 5> 所示。 图 9< 5! 计算 %["][(]的次序 例如,在计算 %[5][9]时,依递归式有 %[5][9]" %"# %[5][5]& %[7][9]& )’ )5 )9 " 4 6 5 944 6 79 ; ’9 ; 54 3 ’7 444 %[5][7]& %[8][9]& )’ )7 )9 " 5 :59 6 ’ 444 6 79 ; 9 ; 54 3 ? ’59 %[5][8]& %[9][9]& )’ )8 )9 " { 8 7?9 6 4 6 79 ; ’4 ; 54 3 ’’ 7?9 3 ? ’59 且 ’ " 7,因此,+[5][9]" 7。 算法 %/$,"012/"# 的主要计算量取决于算法中对 (、! 和 ’ 的 7 重循环。循环体内的计算量为 )(’),而 7 重循环的总次数为 )(#7 )。因此,该算法的计算时间上界为 )(#7 )。算法所占用的 空间显然为 )(#5 )。 相比之下,解同一问题的动态规划算法 %/$,"012/"# 只需计算时间 )(#7 ),而直接递归算法 ,->.,%/$,"012/"# 的计算时间随 # 指数增长。动态规划算法的有效性就在于它充分利用了问题的 !"第 9 章! 递 ! ! 归 子问题重叠性质。不同的子问题个数为 !(!! ),而动态规划算法对于每个不同的子问题只计算 一次,从而节省了大量不必要的计算。由此也可看出,当解某一问题的直接递归算法所产生的递 归树中,相同的子问题反复出现,并且不同子问题的个数又相对较少时,可以采用动态规划算法。 解决重复计算子问题的另一个方法是备忘录方法。与动态规划算法一样,备忘录方法用表 格保存已解决的子问题的答案,在下次需要解此子问题时,只要简单地查看该子问题的解答,而 不必重新计算。与动态规划算法不同的是,备忘录方法的递归方式是自顶向下的,而动态规划算 法则是自底向上递归的。因此,备忘录方法的控制结构与直接递归方法的控制结构相同,区别在 于备忘录方法为每个解过的子问题建立了备忘录以备需要时查看,避免了相同子问题的重复 求解。 备忘录方法为每个子问题建立一个记录项,初始化时,该记录项存入一个特殊值,表示该子 问题尚未求解。在求解过程中,对每个待求子问题,首先查看其相应的记录项。若记录项中存储 的是初始化时存入的特殊值,则表示该子问题是第一次遇到,此时计算出该子问题的解,并保存 在其相应的记录项中,以备以后查看。若记录项中存储的已不是初始化时存入的特殊值,则表示 该子问题已被计算过,其相应的记录项中存储的是该子问题的解答。此时,只要从记录项中取出 该子问题的解答即可,而不必重新计算。 下面的算法 "#"$%&#’()*+%,-.)%/ 是解矩阵连乘积最优计算次序问题的备忘录方法。 %/* "#"$%&#’()*+%,-.)%/(%/* /) { 0 0 0 %/* %,1; 0 0 0 2$+(% !3;% #!/;% $$) 0 0 0 0 2$+(1 !%;1 #!/;1 $$)"[%][1]!4; 0 0 0 +#*5+/ 6$$758-.)%/(3,/); } %/* 6$$758-.)%/(%/* %,%/* 1) { 0 0 0 %/* 7,5; 0 0 0 %2("[%][1])4)+#*5+/ "[%][1]; 0 0 0 %2(% !!1)+#*5+/ 4; 0 0 0 5 !6$$758-.)%/(% $3,1)$8[% "3]!8[%]!8[1]; 0 0 0 9[%][1]!%; 0 0 0 2$+(7 !% $3;7 #1;7 $$){ 0 0 0 0 %/* * !6$$758-.)%/(%,7)$6$$758-.)%/(7 $3,1)$8[% "3]!8[7]!8[1]; 0 0 0 0 %2(* #5){ 0 0 0 0 0 5 !*; 0 0 0 0 0 9[%][1]!7;} 0 0 0 0 } !" 数据结构与算法 ! ! ! "[#][$]!%; ! ! ! &’(%&) %; } 与动态规划算法一样,备忘录算法用数组 ! 记录子问题的最优值。! 初始化为 *,表示相应 的子问题还未被计算。在调用 +,,-%./01#) 时,若 "[#][$]2 *,则表示其中存储的是所要求子问 题的计算结果,直接返回此结果即可。否则与直接递归算法一样,自顶向下地递归计算,并将计 算结果存入 "[#][$]后返回。因此,+,,-%./01#) 总能返回正确的值,但仅在它第一次被调用时 计算,以后的调用就直接返回计算结果。 与动态规划算法一样,备忘录算法 "’",#3’451(/01#) 耗时 "(#7 )。事实上,共有 "(#8 )个 备忘记录项 "[#][$],$ % 9,⋯ ,#,& % $,⋯ ,#。这些记录项的初始化耗费时间 "(#8 )。每个记录 项只填入一次,每次填入时,不包括填入其他记录项的时间,共耗费时间 "( #)。因此,+,,-: %./01#) 填入 "(#8 )个记录项总共耗费计算时间 "(#7 )。由此可见,通过使用备忘录技术,也可 以使直接递归算法的计算时间从 !(8# )降至 "(#7 )。 综上所述,矩阵连乘积的最优计算次序问题可用自顶向下的备忘录算法或自底向上的动态 规划算法在计算时间 "(#7 )内求解。这两个算法都利用了子问题重叠性质。对每个子问题,两 种方法都只解一次,并记录答案。再次遇到该子问题时,不重新求解而简单地取用已得到的答 案。因此,节省了计算量,提高了算法的效率。 !" #" $% 回溯与递归 回溯法是系统地搜索问题的所有解的算法。在问题的解空间树中,回溯法按深度优先策略, 从根结点出发搜索解空间树。算法搜索至解空间树的任一结点时,先判断该结点是否包含问题 的解。如果肯定不包含,则跳过对以该结点为根的子树的搜索,逐层向其祖先结点回溯。否则, 进入该子树,继续按深度优先策略搜索。回溯法求问题的所有解时,要回溯到根,且根结点的所 有子树都被搜索遍才结束。 下面用 # 后问题为例说明回溯法的基本设计思想。 # 后问题要求在 # ’ # 格的棋盘上放置彼此不受攻击的 # 个皇后。按照国际象棋的规则,皇 后可以攻击与之处在同一行或同一列或同一斜线上的棋子。# 后问题等价于在 # ’ # 格的棋盘 上放置 # 个皇后,要求任何 8 个皇后不放在同一行或同一列或同一斜线上。 用 # 元组 6[9:)]表示 # 后问题的解。其中 6[#]表示皇后 $ 放在棋盘的第 $ 行的第 6[#]列。 由于不允许将 8 个皇后放在同一列,所以解向量中的 6[#]互不相同。8 个皇后不能放在同一斜 线上是问题的隐约束。对于一般的 # 后问题,这一隐约束条件可以化成显约束的形式。将 # ’ # 格棋盘看作二维方阵,其行号从上到下,列号从左到右依次编号为 9,8,⋯ ,#。从棋盘左上角到 右下角的主对角线及其平行线(即斜率为 ( 9 的各斜线)上,8 个下标值的差(行号 ( 列号)值相 等。同理,斜率为 ) 9 的每一条斜线上,8 个下标值的和(行号 ) 列号)值相等。因此,若 8 个皇 后放置的位置分别是($,&)和(*,9),且 $ ( & % * ( + 或 $ ) & % * ) +,则说明这 8 个皇后处于同一斜 线上。以上 8 个方程分别等价于 $ ( * % & ( + 和 $ ( * % + ( &。由此可知,只要 + $ ( * + % + & ( + + 成立, !"第 ; 章! 递 ! ! 归 就表明 ! 个皇后位于同一条斜线上。问题的隐约束化成了显约束。 用回溯法解 ! 后问题时,用完全 ! 叉树表示解空间。其中 "#$ 记录当前已找到的可行方 案数。 % &’( ’;% % % %!皇后个数 !% % &’( !);% % % %!当前解 !% % *+’, "#$;% %!当前已找到的可行方案数 !% 可行性约束 -*./0 剪去不满足行、列和斜线约束的子树,实现方法如下: &’( -*./0(&’( 1) { % &’( 2; % 3+4(2 !5;2 #1;2 $$) % &3((.6"(1 "2)!!.6"()[2]")[1]))((()[2]!!)[1]))40(#4’ 7; % 40(#4’ 5; } 下面的解 ! 后问题的回溯法中,递归函数调用 6./1(4./1(*)实现对整个解空间的回溯搜索。 6./1(4./1(&)搜索解空间中第 " 层子树。当 & 8 ’ 时,算法搜索至叶结点,得到一个新的 ! 皇后互 不攻击放置方案,当前已找到的可行方案数 "#$ 增 5。 当 "$! 时,当前扩展结点 # 是解空间中的内部结点。该结点有 )[&]9 5,!,⋯ ,’ 共 ’ 个儿 子结点。对当前扩展结点 # 的每一个儿子结点,由 -*./0 检查其可行性,并以深度优先的方式递 归地对可行子树搜索,或剪去不可行子树。 :+&; 6./1(4./1(&’( () { % &’( &; % &3(( )’)"#$ $$; % 0*"0 % % 3+4(& !5;& #!’;& $$){ % % % )[(]!&; % % % &3(-*./0(())6./1(4./1(( $5); % % % } } 数组 $ 记录了解空间树中从根到当前扩展结点的路径,这些信息已包含了回溯法在回溯时 所需要的信息。利用数组 $ 所含信息,可将上述回溯法表示成非递归形式,进一步省去 %(!)递 !" 数据结构与算法 归栈空间。 解 ! 后问题的非递归迭代回溯法描述如下: !"#$ %&’()*&’(() { + #,) ( !-; + .[-]!/; + 01#23(( )/){ + + .[(]$ !-; + + 01#23((.[(]#!,)44 !(52&’3(())).[(]$ !-; + + #6(.[(]#!,) + + + #6(( !!,)789 $$; + + + 3273 {( $$;.[(]!/;} + + 3273 ( ""; + + } } :; <+ 模 拟 递 归 =&,"# 塔问题是递归算法设计的典型例子。设 "、#、$ 是 < 个塔座。开始时,在塔座 " 上有一 叠共 ! 个圆盘,这些圆盘自下而上,由大到小地叠在一起。各圆盘从小到大编号为 -,>,⋯ ,!,如 图 :; < 所示。现要求将塔座 " 上的这一叠圆盘移到塔座 # 上,并仍按同样顺序叠置。在移动圆 盘时应遵守以下移动规则: 规则!:每次只能移动 - 个圆盘; 规则":任何时刻都不允许将较大的圆盘压在较小的圆盘之上; 规则#:在满足移动规则!和"的前提下,可将圆盘移至 "、#、$ 中任一塔座上。 图 :; <+ =&,"# 塔问题的初始状态 这个问题有一个简单的解法。假设塔座 "、#、$ 排成一个 三角形,"’#’$’" 构成一顺时针循环。在移动圆盘的过程 中,若为奇数次移动,则将最小的圆盘移到顺时针方向的下一 塔座上;若是偶数次移动,则保持最小的圆盘不动。而在其他 > 个塔座之间,将较小的圆盘移到另一塔座上去。 上述算法简洁明确,可以证明它的正确性。但只看算法 的计算步骤,很难理解它的道理,也很难理解它的设计思想。 下面用递归技术来解决同一问题。当 ! % - 时,问题比较简单。此时,只要将编号为 - 的圆盘从 塔座 " 直接移至塔座 # 上即可。当 ! & - 时,需要利用塔座 $ 作为辅助塔座。此时若能设法将 ! ’- 个较小的圆盘依照移动规则从塔座 " 移至塔座 $,然后,将剩下的最大圆盘从塔座 " 移至塔 !"第 : 章+ 递 + + 归 座 !,最后,再设法将 " # ! 个较小的圆盘依照移动规则从塔座 $ 移至塔座 ! 即可。由此可见," 个圆盘的移动问题可分为 " 次 " # ! 个圆盘的移动问题,这又可以递归地用上述方法来做。由此 可以设计出解 #$%&’ 塔问题的递归算法如下: (&’) *$%&’(’%+ %,’%+ ,,’%+ -,’%+ .) { / ’0(% )1){ / / / *$%&’(% "!,,,.,-); / / / 2&(3(,,-); / / / *$%&’(% "!,.,-,,); / / / } } 其中,*$%&’(%,$,4,5)表示将塔座 % 上自下而上、由大到小叠在一起的 " 个圆盘依移动规则移至 塔座 ! 上并仍按同样顺序叠放。在移动过程中,以塔座 $ 作为辅助塔座。2&(3($,4)表示将塔座 % 上的圆盘移至塔座 ! 上。 算法 *$%&’ 以递归形式给出,每个圆盘的具体移动方式不清楚,因此很难用手工移动来模拟 这个算法。然而,这个算法易于理解,也容易证明其正确性,而且易于掌握它的设计思想。像 *$6 %&’ 这样的递归算法,在执行时需要多次调用自身。实现这种递归调用的关键是为算法建立递归 调用工作栈。通常,在一个算法中调用另一算法时,系统需在运行被调用算法之前先完成 7 件 事: ! 将所有实参指针、返回地址等信息传递给被调用算法; " 为被调用算法的局部变量分配存储区; # 将控制转移到被调用算法的入口。 在从被调用算法返回调用算法时,系统也相应地要完成 7 件事: ! 保存被调用算法的计算结果; " 释放分配给被调用算法的数据区; # 依照被调用算法保存的返回地址将控制转移到调用算法。 当有多个算法构成嵌套调用时,按照后调用先返回的原则进行。上述算法之间的信息传递 和控制转移必须通过栈来实现,即系统将整个程序运行时所需的数据空间安排在一个栈中,每调 用一个算法,就为它在栈顶分配一个存储区,每退出一个算法,就释放它在栈顶的存储区。当前 正在运行的算法的数据一定在栈顶。 递归算法的实现类似于多个算法的嵌套调用,只是调用算法和被调用算法是同一个算法。 因此,与每次调用相关的一个重要概念是递归算法的调用层次。若调用一个递归算法的主算法 为第 1 层算法,则从主算法调用递归算法为进入第 ! 层调用;从第 & 层递归调用本算法为进入第 & ’ ! 层调用。反之,退出第 & 层递归调用,则返回至第 & # ! 层调用。为了保证递归调用正确执 行,系统要建立递归调用工作栈,为各层次的调用分配数据存储区。每一层递归调用所需的信息 构成一个工作记录,其中包括所有实参指针,所有局部变量以及返回上一层的地址。每进入一层 !" 数据结构与算法 递归调用,就产生一个新的工作记录压入栈顶。每退出一层递归调用,就从栈顶弹出一个工作 记录。 由于递归算法结构清晰,可读性强,而且容易用数学归纳法来证明算法的正确性,因此它为 设计算法、调试程序带来很大方便。然而,递归算法的运行效率较低,无论是耗费的计算时间还 是占用的存储空间都比非递归算法要多。若在程序中模拟算法的递归调用,则其运行时间可大 为节省。因此,有时希望将递归算法转化为非递归算法。通常,模拟递归算法时,采用一个用户 定义的栈来模拟系统的递归调用工作栈,从而达到将递归算法改为非递归算法的目的。仅仅是 机械地模拟还不能达到减少计算时间和存储空间的目的,还需要根据具体程序的特点对递归调 用工作栈进行简化,尽量减少栈操作,压缩栈存储空间以达到节省计算时间和存储空间的目的。 按照上述思想模拟系统工作栈的算法 !"#$% 描述如下: &$%’ !"#$%(%#( #,%#( ),%#( *,%#( +) { , -#$’. /; , 0("12 3 !0("124#%((); , /5 # !#;/5 ) !);/5 * !*;/5 + !+; , 673!(/,3); , 8!%9.(!0("12:;<(*(3)){ , , / !6$<(3); , , # !/5 #;) !/5 );* !/5 *;+ !/5 +; , , %=(# !!>);$&.(),*); , , .93.{ , , , /5 # !# ">;/5 ) !+;/5 * !*;/5 + !); , , , 673!(/,3); , , , /5 # !>;/5 ) !);/5 * !*;/5 + !+; , , , 673!(/,3); , , , /5 # !# ">;/5 ) !);/5 * !+;/5 + !*; , , , 673!(/,3); , , , } , , } } 在算法的最后一步调用自身的递归算法称为尾递归。在模拟尾递归时通常不需要辅助栈, 只要修改相应的调用参数,作循环调用即可。例如,对于递归算法 !"#$% 的尾递归,可以用下面 的算法来模拟: &$%’ !"#$%(%#( #,%#( ),%#( *,%#( +) { !"第 ? 章, 递 , , 归 ! "#$%&(’ )(){ ! ! ! #)’*$(’ "+,,,-,.); ! ! ! /*0&(,,.); ! ! ! ’ ""; ! ! ! 1")2(,,-); ! ! ! } } 另一个尾递归的例子是前面讨论过的二分搜索算法。消除了尾递归的二分搜索算法描述 如下: $’3 1&)45#($’3 )[],$’3 ,,$’3 %,$’3 4) { ! ! "#$%&(4 )!%) ! ! ! {$’3 / !(% $4)%6; ! ! ! ! $7(, !!)[/])4&384’ /; ! ! ! ! $7(, #)[/])4 !/ "+;&%1& % !/ $+; ! ! ! } ! ! 4&384’ "+; } 9: ;! 应! ! 用 棋盘覆盖问题:在一个 6! " 6! 个方格组成的棋盘中,恰有一个方格与其他方格不同,称该方 格为一特殊方格,且称该棋盘为一特殊棋盘。显然特殊方格在棋盘上出现的位置有 ;! 种情形。 因而对任何 !"(,有 ;! 种不同的特殊棋盘。图 9: ; 中的特殊棋盘是当 ! # 6 时 +< 个特殊棋盘中 的一个。 图 9: ;! ! # 6 时的 一个特殊棋盘 在棋盘覆盖问题中,要用图 9: 9 所示的 ; 种不同形态的 = 形骨牌覆盖给 定的特殊棋盘上除特殊方格以外的所有方格,且任何 6 个 = 形骨牌不得重叠 覆盖。易知,在任何一个 6! " 6! 的棋盘覆盖中,用到的 = 形骨牌个数恰为 (;! $ +)% >。 图 9: 9! ; 种不同形态的 = 形骨牌 !" 数据结构与算法 用分治策略,可以设计出解棋盘覆盖问题的简洁算法。 当 ! " ! 时,将 "! # "! 棋盘分割为 # 个 "! $ $ # "! $ $ 子棋盘如图 %& ’( 所示。 图 %& ’) 棋盘分割 特殊方格必位于 # 个较小子棋盘之一,其余 * 个子棋盘中无特殊方格。为了将这 * 个无特 殊方格的子棋盘转化为特殊棋盘,可以用一个 + 形骨牌覆盖这 * 个较小棋盘的会合处,如图 %& ’, 所示,这 * 个子棋盘上被 + 形骨牌覆盖的方格就成为该棋盘上的特殊方格,从而将原问题 转化为 # 个较小规模的棋盘覆盖问题。递归地使用这种分割,直至棋盘简化为 $ - $ 棋盘。 实现这种分治策略的递归算法 ./01123(45 可实现如下: 678 ,3(45[’#][’#],1690,86:0 !$; ;365 ./01123(45(678 84,678 8.,678 54,678 5.,678 1690) { ) ) ) 678 1,8; ) ) ) 6<(1690 !!$)408=47; ) ) ) 8 !86:0 $$;) %!+ 形骨牌号 !% ) ) ) 1 !1690%";) %!分割棋盘 !% ) ) ) %!覆盖左上角子棋盘 !% ) ) ) 6<(54 #84 $1 >> 5. #8. $1) ) ) ) ) %!特殊方格在此棋盘中 !% ) ) ) ) ./01123(45(84,8.,54,5.,1); ) ) ) 0:10 {%!此棋盘中无特殊方格 !% ) ) ) ) %!用 8 号 + 形骨牌覆盖右下角 !% ) ) ) ) ,3(45[84 $1 "$][8. $1 "$]!8; ) ) ) ) %!覆盖其余方格 !% ) ) ) ) ./01123(45(84,8.,84 $1 "$,8. $1 "$,1);} ) ) ) %!覆盖右上角子棋盘 !% ) ) ) 6<(54 #84 $1 >> 5. )!8. $1) !"第 % 章) 递 ) ) 归 ! ! ! ! %!特殊方格在此棋盘中 !% ! ! ! ! "#$%%&’()*(+),+" $%,*),*",%); ! ! ! $,%$ {%!此棋盘中无特殊方格 !% ! ! ! ! %!用 + 号 - 形骨牌覆盖左下角 !% ! ! ! ! .’()*[+) $% "/][+" $%]!+; ! ! ! ! %!覆盖其余方格 !% ! ! ! ! "#$%%&’()*(+),+" $%,+) $% "/,+" $%,%);} ! ! ! %!覆盖左下角子棋盘 !% ! ! ! 01(*) )!+) $% 22 *" #+" $%) ! ! ! ! %!特殊方格在此棋盘中 !% ! ! ! ! "#$%%&’()*(+) $%,+",*),*",%); ! ! ! $,%$ {%!用 + 号 - 形骨牌覆盖右上角 !% ! ! ! ! .’()*[+) $%][+" $% "/]!+; ! ! ! ! %!覆盖其余方格 !% ! ! ! ! "#$%%&’()*(+) $%,+",+) $%,+" $% "/,%);} ! ! ! %!覆盖右下角子棋盘 !% ! ! ! 01(*) )!+) $% 22 *" )!+" $%) ! ! ! ! %!特殊方格在此棋盘中 !% ! ! ! ! "#$%%&’()*(+) $%,+" $%,*),*",%); ! ! ! $,%$ {%!用 + 号 - 形骨牌覆盖左上角 !% ! ! ! ! .’()*[+) $%][+" $%] 3 +; ! ! ! ! %!覆盖其余方格 !% ! ! ! ! "#$%%&’()*(+) $%,+" $%,+) $%,+" $%,%);} } 上述算法中,用 4 维整型数组 .’()* 表示棋盘。.’()*[5][5]是棋盘的左上角方格。+0,$ 是 算法中的一个全局整型变量,用来表示 - 形骨牌的编号,其初始值为 5。算法的输入参数是: ! +):棋盘左上角方格的行号; " +":棋盘左上角方格的列号; # *):特殊方格所在的行号; $ *":特殊方格所在的列号; % %06$:4! ,棋盘规格为 4! " 4! 。 设 #(!)是算法 "#$%%&’()* 覆盖一个 4! " 4! 棋盘所需的时间。从算法的分割策略可知, #(!)满足如下递归方程 #(!)$ %(/) 7#(! & /)’ %(/)( ( ! $ 5 ! ){ 5 !" 数据结构与算法 解此递归方程可得 !(")# $(!" )。由于覆盖 "" % "" 棋盘所需的 # 形骨牌个数为(!" & $)’ %,故算法 &’())*+,-. 是一个在渐近意义下最优的算法。 本 章 小 结 本章介绍了递归的概念,以及递归在数据结构和算法设计中的广泛应用。递归算法的设计 与应用是本章的重点内容。本章以较大篇幅和详细的实例阐述了分治法、动态规划和回溯法这 % 个实践中常用的使用递归技术的算法设计策略。后续各章中还会反复用到这些算法设计策 略。本章还讨论了递归算法的工作原理和模拟递归的方法,这有助于进一步深刻理解递归算法 和灵活运用递归技术。最后以棋盘覆盖问题为例讨论了递归算法的应用。 习/ / 题 !" #$ 试写一个计算 0+1((!)的递归算法。 !" %$ 试写一个解 2+)(3’4) 排列问题的递归算法。 !" &$ 试写一个删除链表的表尾元素的递归算法。 !" ’$ 试写一个将链表反转的递归算法。 !" !$ 试将计算第 ( 个 567+8,&&6 数的递归算法改写为动态规划算法。 !" ($ 设 ,[9:8 : $]是已排好序的数组。试改写二分搜索算法,使得当搜索元素 ) 不在数组中时,返回小于 ) 的最大元素位置 * 和大于 ) 的最小元素位置 +。当搜索元素在数组中时,* 和 + 相同,均为 ) 在数组中的位置。 !" )$ 设 ,())# -9 . -$ ) . ⋯ . -/ )/ 是一个 / 次多项式。假设已有一算法能在时间 $(*)内计算一个 * 次多 项式与一个 $ 次多项式的乘积,以及一个算法能在时间 $(*0+1*)内计算 " 个 * 次多项式的乘积。对于任意给定 的 / 个整数 ($ ,(" ,⋯ ,(/ ,设计一个有效算法,计算出满足 ,(($ )# ,((" )# ⋯ # ,((/ )# 9 且最高次项系数为 $ 的 / 次多项式 ,()),并分析算法的效率。 !" *$ 设 ( 个不同的整数排好序后存于 ;[9:8 & $]中。若存在下标 *,$$*$(,使得 !(*)# *,设计一个有效 算法找到这个下标。要求算法在最坏情况下的计算时间为 $(0+1()。 !" +$ 设 ;[9:8 : $]是 ( 个元素的数组。对任一元素 ),设 <(=)>{6 ? ;[6]> =}。当 0 1())0 2 (’ " 时,称 ) 为 ! 的主元素。设计一个线性时间算法,确定 ;[9:8 : $]是否有一个主元素。 !" #,$ 若在习题 @A B 中,数组 ! 中元素不存在可以比较大小的顺序关系,只能测试任意 " 个元素是否相等, 试设计一个有效算法确定 ! 是否有一主元素。算法的计算复杂性应为 $((0+1()。更进一步,能找到一个线性 时间算法吗? !" ##$ 设 ,[9:8 : $]是有 ( 个元素的数组,"(9$"$( & $)是非负整数。试设计一个算法将子数组 ,[9:C] 与 ,[C D $:8 : $]换位,要求算法在最坏情况下耗时 $((),且只用到 $($)的辅助空间。 !" #%$ 给定数组 ,[9:8 : $],试设计一个算法,在最坏情况下用 %(’ @ : " 次比较找出 ,[9:8 : $]中元素的最 大值和最小值。 !" #&$ 给定数组 ,[9:8 : $],试设计一个算法,在最坏情况下用 ( . 0+1 ( & " 次比较找出 ,[9:8 : $]中元素 的最大值和次大值。 !" #’$ 设 1$ ,1" ,⋯ ,1" 是整数集合,其中每个集合 1*($$*$")中整数取值范围是 $ E (,且 % " * # $ 1* # (,试 设计一个算法在时间 $(()内将 1$ ,1" ,⋯ ,1" 分别排序。 !" #!$ 设 F[9:8 : $]和 G[9:8 : $]为 " 个数组,每个数组中含有 ( 个已排好序的数。试设计一个 $(0+1() !!第 @ 章/ 递 / / 归 时间的算法,找出 ! 和 " 的 !# 个数的中位数。 !" #$% 考虑国际象棋棋盘上某个位置的一只马,它是否可能只走 "# 步,正好走过除起点外的其他 "# 个位 置各一次?如果有一种这样的走法,则称所走的这条路线为一条马的周游路线。试设计一个算法找出一条马的 周游路线。 !" #&% $%&’ 码是一个长度为 !# 的序列。序列中无相同元素,每个元素都是长度为 # 位的串,相邻元素恰 好只有 ( 位不同。设计一个算法对任意的 # 构造出相应的 $%&’ 码。 !" #’% 设有 # 个运动员要进行网球循环赛。设计一个满足以下要求的比赛日程表: ! 每个选手必须与其他 # ) ( 个选手各赛一次; " 每个选手一天只能赛一次; # 当 # 是偶数时,循环赛进行 # $ ( 天。当 # 是奇数时,循环赛进行 # 天。 !!" 数据结构与算法 书书书 第 ! 章" 排序与选择 学习目标 ! 理解排序问题的实质。 ! 掌握简单排序算法的设计思想与分析方法。 ! 掌握快速排序算法的设计思想与分析方法。 ! 理解随机化思想在快速排序算法中的应用。 ! 理解三数取中划分算法和三划分算法对快速排序算法的改进策略。 ! 掌握合并排序算法的基本思想及实现方法。 ! 掌握计数排序算法的设计思想与分析方法。 ! 掌握桶排序算法的设计思想与分析方法。 ! 理解线性时间排序与基于比较排序算法的主要差别和适用范围。 ! 掌握平均情况下线性时间选择算法的设计思想与分析方法。 ! 掌握最坏情况下线性时间选择算法的设计思想与分析方法。 !" #$ 简单排序算法 按照某个线性序(例如,数的小于关系)对一些对象进行排序是用计算机处理信息时经常要 做的一项基本工作,有必要对它进行详细的讨论。在一般情况下,排序问题的输入是 ! 个数 "[%],"[&],⋯ ,"[! # #]的一个序列,要设计一个有效的排序算法,产生输入序列的一个重排, 使序列元素按从小到大的顺序排列。输入序列通常是一个有 ! 个元素的数组,当然也可以用其 他形式来表示输入,如链表等。在实际中,待排序的对象往往不是单一的数而是一个记录,其中 有一个关键字域 ’(),它是排序的根据。在 ’() 的数据类型上定义了某个线性序"。例如,整数、 实数、字符串等都可以作为键。记录的其他数据称为卫星数据,即它们都是以 ’() 为中心的。在 一个实际的排序算法中,对关键字重排时,卫星数据也要随关键字一起移动。如果每个记录都很 大,可以对一组指向各个记录的指针进行排序,以求减少数据移动量。对于排序算法来说,不论 待排序对象是单个数值或是记录,它们的排序方法都一样。在排序时,待排序记录的键值可能有 相同者,对于键值相同的记录通常并不要求它们之间应怎样排列,只要求在最后输出时,键值小 者排在键值大者之前。 对排序算法计算时间的分析可以遵循若干种不同的准则,通常以排序过程所需要的算法步 数作为度量,有时也以排序过程中所作的键值比较次数作为度量。特别是当一次键值比较需要 较长时间时(例如,当键是较长的字符串时),常以键值比较次数作为排序算法计算时间复杂性 的度量。当排序算法需要移动记录,且记录都很大时,还应该考虑记录的移动次数,究竟采用哪 种度量方法要根据具体情况而定。 !" #" #$ 冒泡排序 最简单的排序方法是冒泡排序方法。这种方法的基本思想是,将待排序的记录看作是竖着 排列的“气泡”,键值较小的记录比较轻,从而要往上浮。在冒泡排序算法中要对这个“汽泡”序 列处理若干遍。所谓一遍处理,就是自底向上检查一遍这个序列,并时刻注意两个相邻的记录的 顺序是否正确。如果发现两个相邻记录的顺序不对,即“轻”的记录在下面,就变换它们的位置。 显然,处理一遍之后,“最轻”的记录就浮到了最高位置,处理 ! 遍之后,“次轻”的记录就浮到了 次高位置。在作第 ! 遍处理时,由于最高位置上的记录已是“最轻”记录,所以不必检查。一般 地,第 " 遍处理时,不必检查第 " 高位置以上的记录。设待排序的数组段是 #[$]% #[&],冒泡排 序算法可实现如下: ’(") *+**$,(-.,/ #[],"0. $,"0. &) {1 !!冒泡排序算法 !! 1 "0. ",2; 1 3(&(" "$ #4;" $"&;" ##) 1 1 3(&(2 "";2 %$;2 &&) 1 1 1 5(/678#6(#[2 &4],#[2]); } 上述冒泡排序算法中,待排序元素类型是 -.,/,算法根据 -.,/ 类型元素的键值对数组元素 # [$]% #[&]进行排序。算法中用到的关于 -.,/ 类型变量的一些常用运算许多排序算法中都会用 到,如交换两个元素 9 和 : 值的运算 78#6(9,:)等定义如下: ;),3"0, $,77(9,:)(<,=(9)$<,=(:)) ;),3"0, ,>(9,:)(!$,77(9,:)?? !$,77(:,9)) ;),3"0, 78#6(9,:){-.,/ . "9;9 ":;: ".;} ;),3"0, 5(/678#6(9,:)"3($,77(:,9))78#6(9,:); !"# 数据结构与算法 其中,!"##($,%)比较 $ 和 % 的键值,等价于 &"’( $)$&"’( %);"(( $,%)等价于 &"’( $) ""&"’(%);#)*+($,%)交换两个元素 $ 和 % 值;,-.+#)*+($,%)等价于语句 /0(!"##(%,$)) #)*+($,%),即当 &"’(%)$&"’($)时,交换 $ 和 % 值。 !" #" $% 插入排序 插入排序的基本思想是,对数组元素 *[!]1 *[2],经过前 ! 3 ! 遍处理后,*[!],*[! 4 5],⋯ , *[/ 3 5]已排好序。下一轮处理就是要将 *[/]插入 *[!],*[! 4 5],⋯ ,*[/ 3 5]的适当位置,使得 *[!],*[! 4 5],⋯ ,*[/]是排好序的序列。要达到这个目的,可以用顺序比较的方法,首先比较 *[/]和 *[/ 3 5],如果 *[/ 3 5]"*[/],则 *[!],⋯ ,*[/]已排好序,第 ! 遍处理就结束了;否则交换 *[/ 3 5]与 *[/]的位置,继续比较 *[/ 3 5]和 *[/ 3 6],直到找到某一个位置 ",(5"7"/ 3 5),使 得*[7]"*[7 4 5]时为止。上述元素插入过程由算法 /8#"29 来完成。 :-/; /8#"29(<9". *[],/89 !,/89 /) {= !!元素 *[/]插入数组 *[!:/]!! = <9". : "*[/]; = )>/!"(/ %! ?? !"##(:,*[/ &5])){*[/]"*[/ &5];/ &&;} = *[/]":; } 插入排序算法 /8#"29/-8 则反复调用 /8#"29 来完成排序任务。 :-/; /8#"29/-8(<9". *[],/89 !,/89 2) {= !!插入排序算法 !! = /89 /; = 0-2(/ "! #5;/ $"2;/ ##)/8#"29(*,!,/); } !" #" &% 选择排序 选择排序算法的基本思想是对待排序的元素序列 *[!]1 *[2]进行 # 3 ! 遍处理,第 ! 遍处理 是将 *[! 4 / 3 5],⋯ ,*[2]中最小者与 *[! 4 / 3 5]交换位置。这样,经过 ! 遍处理之后,较小的 ! 个元素的位置已经是正确的了。确定 *[/:2]中最小元素下标的算法 ./8/ 可表述如下: /89 ./8/(<9". *[],/89 /,/89 2) { !!确定 *[/:2]中最小元素下标 !! = /89 7,./8 "/; !"#第 @ 章= 排序与选择 ! "#$(% "& #’;% $"$;% ##)&"(()**(+[%],+[,&-])),&- "%; ! $)./$- ,&-; } 利用函数 ,&-&,选择排序算法 *)()0.&#- 可实现如下: 1#&2 *)()0.&#-(3.), +[],&-. (,&-. $) { !!选择排序算法 !! ! &-. &; ! "#$(& "(;& $$;& ##){&-. % ",&-&(+,&,$);*4+5(+[&],+[%]);} } !" #" $% 简单排序算法的计算复杂性 用前面所介绍的 6 个算法对排序数组元素 +[7],+[8],⋯ ,+[- 9 ’]排序都需要 !("8 )计算 时间,并且对每个算法都存在某个由 " 个元素组成的输入序列,使它们确实需要 !("8 )计算时 间。 首先,考虑冒泡排序算法。由于 0#,5*4+5 运算需要 !(’)计算时间,因此冒泡排序算法的循 环体耗时 !(’),由此可知整个算法所需的时间为 # " # $ 8 # #%8 & $ 7 !(’)$ ! # "%’ # $ ’ # # & $ ’ ’( )’ $ !("8 ) ! ! 最坏情况在输入元素序列完全逆序排列时发生。另一方面,即使不需要交换位置,即输入的 元素序列已经排好序,算法仍需执行 "(" 9 ’): 8 次元素比较,所以算法至少需要 !("8 )的计算 时间。 其次,考虑插入排序算法。容易看出,对于固定的 #,&-*)$.(+,7,&)在最坏情况下需要 !(#) 计算时间。所以整个插入排序算法所需的时间为 # "%’ # $ ’ !(#)$ ! # "%’ # $ ’ #( )’ $ !("8 ) ! ! 最坏情况在输入元素序列完全逆序排列时发生。此时对任意的 # 算法 3-*)$. 需要作 # 次的 比较和交换。所以在整个插入排序算法中执行的比较和交换次数为 # "%’ # $ ’ # $ "(" % ’)( 8。由此即 知,在最坏情况下,插入排序算法需要 !("8 )计算时间。 最后,考察选择排序算法。选择排序算法的第 # 遍处理是将 +[& 9 ’],⋯ ,+[- 9 ’]中最小者 与 +[& 9 ’]交换位置。而确定 +[& 9 ’:- 9 ’]中最小元素下标的算法 ,&- 需要 !(" % #)计算时 间。所以整个算法需要的计算时间为 # "%’ # $ ’ !(" % #)$ ! # "%’ # $ ’ (" % #)( )’ $ !("8 ) !"# 数据结构与算法 ! ! 另一方面,对于任何输入序列,选择排序算法总要执行 !(! " ")# # 次元素比较,所以选择排 序算法至少需要 !(!# )计算时间。 $% #! 快速排序算法 前面介绍的 & 个简单排序算法在最坏情况及平均情况下都需要 $(!# )计算时间。事实上, 在判定树计算模型下,任何一个基于比较的排序算法都需要 !(!’()!)计算时间。如果能设计一 个需要 $(!’()!)时间的排序算法,则在渐近的意义上,这个排序算法就是最优的。许多排序算 法都追求这个目标。 下面讨论快速排序算法,它在平均情况下需要 $(!’()!)时间。 !" #" $% 算法基本思想及实现 快速排序算法是基于分治策略的排序算法,其基本思想是,对于输入的子数组 *[’:+],按以 下 & 个步骤进行排序: ! 分解(,-.-/0):以 *[+]为基准元素将 *[’:+]划分成 & 段 *[’:- 1 "]、*[-]和 *[- 2 ":+],使 得 *[’:- 1 "]中任何一个元素小于等于 *[-],*[- 2 ":+]中任何一个元素大于等于 *[-]。下标 % 在划分过程中确定。 " 递归求解(3(4560+):通过递归调用快速排序算法分别对 *[’:- 1 "]和 *[- 2 ":+]进行 排序。 # 合并(70+)0):由于对 *[’:- 1 "]和 *[- 2 ":+]的排序是就地进行的,所以在 *[’:- 1 "]和 *[- 2 ":+]都已排好序后不需要执行任何计算,*[’:+]就已排好序。 基于这个思想,可实现快速排序算法如下: .(-/ 56-89:(+;(<;0= *[],-4; ’,-4; +) {! !!快速排序算法 !! ! -4; -; ! ->(+ $"’)+0;6+4; ! - "?*+;-;-(4(*,’,+); ! 56-89:(+;(*,’,- &");! ! ! !!对左半段排序 !! ! 56-89:(+;(*,- #",+);! ! ! !!对右半段排序 !! } 对含有 ! 个元素的数组 *[@:4 1 "]进行快速排序只要调用 56-89:(+;(*,@,4 1 ")即可。 上述算法中的函数 ?*+;-;-(4,以一个确定的基准元素 *[+]对子数组 *[’:+]进行划分,它是快 速排序算法的关键。 !"#第 $ 章! 排序与选择 !"# $%&#!#!’"((#)* %[],!"# +,!"# &) { !!元素划分算法 !! , !"# ! "+ &-,. "&;(#)* / "%[&]; , !!将 %"0 的元素交换到右边区域 !! , !!将 $"0 的元素交换到左边区域 !! , 1’&(;;) , { , , 23!+)(+)44(%[ ##!],/)); , , 23!+)(+)44(/,%[ &&.]))!1(. ""+)5&)%6; , , !1(! %".)5&)%6; , , 42%$(%[!],%[.]); , } , 42%$(%[!],%[&]); , &)#7&" !; } $%&#!#!’" 对 %[+:&]进行划分时,以元素 / 8 %[&]作为划分的基准,分别从左、右两端开始,扩 展 9 个区域 %[+:!]和 %[.:&],使得 %[+:!]中元素小于或等于 !,而 %[.:&]中元素大于或等于 !。初 始时," # $ % - 且 & # ’。 在 1’& 循环体中,下标 & 逐渐减小," 逐渐增大,直到 %[!]%8 / %8 %[.]。如果这两个不等式 是严格的,则 %[!]不会是左边区域的元素,而 %[.]不会是右边区域的元素。此时若 " $.,就应该 交换 %[!]与 %[.]的位置,扩展左右两个区域。 1’& 循环重复至 " %8 . 时结束,这时 %[+:&]已被划分成 %[+:! : -]、%[!]和 %[! ; -:&],且满足 %[+:! : -]中元素不大于 %[! ; -:&]中元素。在 $%&#!#!’" 结束时返回划分点 "。 事实上,函数 $%&#!#!’" 的主要功能就是将小于 ! 的元素放在原数组的左半部分。而将大于 ! 的元素放在原数组的右半部分。其中,有一些细节需要注意。例如,算法中的下标 " 和 & 不会超 出 %[+:&]的下标界。另外,在快速排序算法中选取 %[&]作为基准可以保证算法正常结束。 !" #" #$ 算法的性能 对于输入序列 %[+:&],$%&#!#!’" 的计算时间显然为 ((’ % $ % -)。 对含有 ) 个元素的数组 %[<:" : -]进行快速排序的运行时间与划分是否对称有关,其最坏 情况发生在划分过程产生的两个区域分别包含 ) % - 个元素和 - 个元素的时候。由于函数 $%&#!= #!’" 的计算时间为 (()),所以如果算法 $%&#!#!’" 的每一步都出现这种不对称划分,则其计算时 间复杂性 *())满足 *())# + + ((-)+ + + )"- *() % -), (()) ) - { - , , 解此递归方程可得 *())# (()9 )。 !"# 数据结构与算法 在最好情况下,每次划分所取的基准都恰好为中值,即每次划分都产生 ! 个大小为 ! " ! 的区 域,此时,"#$%&%&’( 的计算时间 #(!)满足 #(!)$ % % &())% % % % !") !#(! " !)’ &(!) ! ( { ) 其解为 #(!)$ &(!*’+!)。 可以证明,快速排序算法在平均情况下的时间复杂性也是 &(!*’+!),这在基于比较的排序 算法类中算是快速的了,快速排序也因此而得名。 !" #" $% 随机快速排序算法 快速排序算法的性能取决于划分的对称性。通过修改函数 "#$%&%&’(,可以设计出采用随机选 择策略的快速排序算法。在快速排序算法的每一步中,当数组还没有被划分时,可以在 #[*:$]中 随机选出一个元素作为划分基准,这样可以使划分基准的选择是随机的,从而可以期望划分是较 对称的。随机的划分算法可实现如下: &(% $#(,’-"#$%&%&’((.%/- #[],&(% *,&(% $) { !!随机化划分算法 !! 0 &(% & "$#(,’-&(*,$); 0 12#"(#[&],#[*]); 0 $/%3$( "#$%&%&’((#,*,$); } 其中函数 $#(,’-&(*,$)产生 ) 和 * 之间的一个随机整数,且产生不同整数的概率相同。 随机的快速排序算法通过调用 $#(,’-"#$%&%&’( 产生随机划分。 4’&, $#(,’-53&671’$%(.%/- #[],&(% *,&(% $) { !!随机快速排序算法 !! 0 &(% &; 0 &8($ $"*)$/%3$(; 0 & "$#(,’-"#$%&%&’((#,*,$); 0 53&671’$%(#,*,& &));0 0 0 0 0 !!对左半段排序 !! 0 53&671’$%(#,& #),$);0 0 0 0 0 !!对右半段排序 !! } !" #" &% 非递归快速排序算法 对快速排序算法的另一个改进是模拟递归。当待排序数组 #[*:$]中有 ! 个元素时,快速排 !"#第 9 章0 排序与选择 序算法 !"#$%&’() 的递归调用在最坏情况下可能耗费栈空间 !(")。如果让左半段数组*[+:# , -] 和右左半段数组 *[# . -:(]中元素个数较少者先排序,则在最坏情况下只耗费栈空间 +’/"。事实 上,设待排序数组大小为 " 时快速排序算法所需栈空间为 #("),若采用小者优先递归的策略,则 #(")满足 #(")" $ $ 01 1 1 1 ""- #(" % 2)& - " ’ { - 由此可见,#(")"+’/"。 用上述策略对快速排序算法的改进如下: 3’#4 !"#$%&’()(5)67 *[],#8) +,#8) () {1 !!小者优先递归!! 1 #8) #; 1 #9(( $"+)(6)"(8; 1 # ":*()#)#’8(*,+,(); 1 #9(# &+ %( &#){!"#$%&’()(*,# #-,();!"#$%&’()(*,+,# &-);} 1 6+&6{!"#$%&’()(*,+,# &-);!"#$%&’()(*,# "-,();} } 进一步采用模拟递归技术可以消去算法的递归调用。 ;469#86 :"&<2(=,>,&)?"&<(>,&);?"&<(=,&); 1 3’#4 !"#$%&’()(5)67 *[],#8) +,#8) () 1 {1 !!非递归快速排序算法 !! 1 1 #8) #; 1 1 @)*$% & "@)*$%58#)(); 1 1 :"&<2(+,(,&); 1 1 A<#+6(!@)*$%B7:)C(&)) 1 1 1 { 1 1 1 1 + "?’:(&);( "?’:(&); 1 1 1 1 #9(( $"+)$’8)#8"6; 1 1 1 1 # ":*()#)#’8(*,+,(); 1 1 1 1 #9(# &+ %( &#){:"&<2(+,# &-,&);:"&<2(# #-,(,&);} 1 1 1 1 6+&6{:"&<2(# #-,(,&);:"&<2(+,# &-,&);} 1 1 1 } 1 } 其中,:"&<2(=,>,&)定义为连续 2 次进栈运算 ?"&<(>,&)和 ?"&<(=,&)。 !"# 数据结构与算法 !" #" $% 三数取中划分算法 从递归算法的递归树可以看出,递归算法做了大量小规模数组递归调用。如果在递归算法 中遇到小规模数组时终止递归,改用非递归算法,将有效地改进递归算法的性能。例如在快速排 序算法的开头增加语句: ! ! "#($ &% $"&)"’()$*"+’(,,%,$); 在数组规模较小时终止递归,改用非递归算法 "’()$*"+’(,,%,$)进行排序可以改进快速排序 算法的性能。其中参数 & 用于控制何时终止递归。实验表明 & 的值在 - . /- 之间效果较好。 采用这个策略可以使快速排序算法的性能改进 012 左右。 上述思想还可以用下面的办法来实现,即在快速排序算法的开头增加语句: ! ! "#($ &% $"&)$)*3$’; 终止递归。在整个算法结束后再用 "’()$*"+’(,,%,$)将已大致排好序的数组排序。 对快速排序算法的划分对称性还有可以改进的余地。4 数取中划分算法的主要思想基于划 分基准的选取。对于待排序数组 ,[%:$],算法选取 ,[%]、,[$]和 ,[(% 5 $)6 /]这 4 个数的中位数 作为划分基准,从而改进划分的对称性。综合上述改进策略的三数取中快速排序算法如下: 78)#"’) & 01 9+"8 :3";<(+$*(=*)> ,[],"’* %,"’* $) {!!4 数取中快速排序算法 !! ! "’* "; ! "#($ &% $"&)$)*3$’; ! (?,@(,[(% #$)!/],,[$ &0]); ! ;+>@(?,@(,[%],,[$ &0]); ! ;+>@(?,@(,[%],,[$]); ! ;+>@(?,@(,[$ &0],,[$]); ! " "@,$*"*"+’(,,% #0,$ &0); ! :3";<(+$*(,,%," &0); ! :3";<(+$*(,," #0,$); } 9+"8 (+$*(=*)> ,[],"’* %,"’* $) { ! :3";<(+$*(,,%,$); !"#第 A 章! 排序与选择 ! "#$%&’"(#(),*,&); } 三数取中快速排序算法比原快速排序算法的性能改进 +,- . +/- 。 !" #" !$ 三划分快速排序算法 当待排序数组 )[*:&]中有大量键值相同的元素时,采用三划分快速排序算法可以明显改进 算法的性能。该算法的基本思想是在划分阶段以 0 1 )[&]为划分基准将待排序数组 )[*:&]划分 为左、中、右 2 段 )[*:3]、)[3 4 5:" 6 5]和 )[":&]。其中左段数组 )[*:3]中元素键值小于 !,中段 数组 )[3 4 5:" 6 5]中元素键值等于 !,右段数组 )[":&]中元素键值大于 !。其后,算法对左右两 段数组递归排序。在具体实现三划分时,先将键值与 ! 相同的元素分别交换到左右 + 段数组的 左右 + 端。在搜索游标 " 和 # 交叉后,再将这些元素交换到中段数组中。 实现上述思想的三划分快速排序算法描述如下: 0("7 89":;$(&’(<’%= )[],"#’ *,"#’ &) {! !!2 划分快速排序算法 !! ! "#’ ",3,;,>,8;<’%= 0; ! "?(& $"*)&%’9&#; ! 0 ")[&];" "* &5;3 "&;> "* &5;8 "&; ! ?(&(;;) ! ! { ! ! ! @A"*%(*%$$()[ ##"],0)); ! ! ! @A"*%(*%$$(0,)[ &&3]))"?(3 ""*)B&%);; ! ! ! "?(" %"3)B&%);; ! ! ! $@)>()["],)[3]); ! ! ! "?(%8()["],0)){> ##;$@)>()[>],)["]);} ! ! ! "?(%8(0,)[3])){8 &&;$@)>()[8],)[3]);} ! ! } ! $@)>()["],)[&]);3 "" &5;" "" #5; ! ?(&(; "* ;; $>;; ##,3 &&)$@)>()[;],)[3]); ! ?(&(; "& &5;; %8;; &&," ##)$@)>()[;],)["]); ! 89":;$(&’(),*,3); ! 89":;$(&’(),",&); } 三划分快速排序算法在待排序数组 )[*:&]中有大量键值相同的元素时效率较高。另一方 面,当待排序数组 )[*:&]中没有大量键值相同的元素时,三划分快速排序算法也不降低原快速排 序算法的效率。 !"" 数据结构与算法 !" #$ 合并排序算法 !" #" $% 算法基本思想及实现 合并排序算法是用分治策略实现对 ! 个元素进行排序的算法。其基本思想是:当 ! % & 时终 止排序,否则将待排序元素分成大小大致相同的 ’ 个子集,分别对 ’ 个子集进行排序,最终将排 好序的子集合并成为所要求的排好序的集合。合并排序算法可递归地描述如下。 ()*+ ,-./-0).1(21-, 3[],*41 5,*41 .) {$ !!合并排序算法 !! $ *41 , "(. #5)!’;$ $ $ $ $ !!取中点 !! $ *6(. $"5).-17.4; $ ,-./-0).1(3,5,,); !!对左半段排序!! $ ,-./-0).1(3,, #&,.); !!对右半段排序!! $ ,-./-38(3,8,5,,,.); !!合并到数组 8!! $ 9):;(8,3,5,.); !!复制回数组 3!! } 其中,算法 ,-./-38 合并 ’ 个排好序的数组段到一个新的数组 8 中,然后由 9):; 将合并后的数组 段再复制回数组 3 中。 ()*+ ,-./-38(21-, 3[],21-, 8[],*41 5,*41 ,,*41 .) {$ !!合并 3[5:,]}和 3[, #&:.]到 8[5:.]!! $ *41 * "5,< ", #&,= "5; $ !!取 ’ 段中较小元素到数组 8 中 !! $ >?*5-((* $",)@@(< $".)) $ $ *6(5-00(3[*],3[<]))8[= ##]"3[* ##]; $ $ -50- 8[= ##]"3[< ##]; $ !!处理剩余元素 !! $ *6(* %,)6).(* "<;* $".;* ##)8[= ##]"3[*]; $ -50- 6).(;* $",;* ##)8[= ##]"3[*]; } ,-./-38 和 9):; 显然可在 "(!)时间内完成,因此合并排序算法对 ! 个元素进行排序,在最 坏情况下所需的计算时间 #(!)满足 !!!第 ! 章$ 排序与选择 !(")# $(!)% % % % % % ""! "!(" & ")’ $(") " ( { ! 解此递归方程可知 !(")# $("#$%")。由于排序问题的计算时间下界为 !("#$%"),故合并 排序算法是一个渐近最优算法。 !" #" $% 对基本算法的改进 虽然上述合并排序算法已是一个渐近最优算法,但仍有改进的余地。首先对于算法 &’(%’)* 可以做如下改进: +$,- &’(%’(./’& )[],,0/ #,,0/ &,,0/ () { 1 1 ,0/ ,,2,3; 1 1 4$((, "& #!;, %#;, &&)*[, &!]")[, &!]; 1 1 4$((2 "&;2 $(;2 ##)*[( #& &2]")[2 #!]; 1 1 4$((3 "#;3 $"(;3 ##) 1 1 1 1 ,4(#’55(*[,],*[2])))[3]"*[, ##]; 1 1 1 1 ’#5’ )[3]"*[2 &&]; } 该算法借助于辅助数组 ),将合并后的数组仍存放在数组 * 中。首先将 )[#:&]顺序复制到 *[#:&]中,)[& 6 !:(]逆序复制到 *[& 6 !:(]中,然后从 *[#:(]的两头开始,合并到数组 * 中。 用此合并技术的合并排序方法如下: +$,- &’(%’5$(/(./’& )[],,0/ #,,0/ () { 1 1 ,0/ & "(( ##)!"; 1 1 ,4(( $"#)(’/7(0; 1 1 &’(%’5$(/(),#,&); 1 1 &’(%’5$(/(),& #!,(); 1 1 &’(%’(),#,&,(); } 上述算法形式上消除了从数组 ) 复制回数组 * 的 8$9: 运算,但没有实际省去这些运算。在 算法递归调用时,交替地使用数组 ) 和数组 * 为辅助数组,可以实际省去复制运算。另一方面, 在数组规模较小时终止递归,改用非递归算法排序可以有效地改进递归算法的效率。采用上述 改进措施可使算法效率提高约 ;<= 。改进后的合并排序方法如下: !"" 数据结构与算法 !"#$ %&’(&)"’*+,(-*&% .[],-*&% /[],#0* 1,#0* ’) { 2 2 #0* % "(1 #’)!3; 2 2 #4(’ &1 $"56){#0)&’*#"0(.,1,’);’&*7’0;} 2 2 %&’(&)"’*+,(/,.,1,%); 2 2 %&’(&)"’*+,(/,.,% #5,’); 2 2 %&’(&./(/,.,1,%,’); } !"#$ %&’(&)"’*(-*&% .[],#0* 1,#0* ’) { 2 2 #0* #; 2 2 4"’(# "1;# $"’;# ##)/[#]".[#]; 2 2 %&’(&)"’*+,(.,/,1,’); } !" #" #$ 自底向上的合并排序算法 对于算法 %&’(&)"’*,还可以从多方面对它进行改进。例如,从分治策略的机制入手,容易消 除算法中的递归。事实上,算法 %&’(&)"’* 的递归过程只是将待排序集合一分为二,直至待排序 集合只剩下 5 个元素为止,然后不断合并 3 个排好序的数组段。按此机制,可以首先将数组 ! 中 相邻元素两两配对,用合并算法将它们排序,构成 " # 3 组长度为 3 的排好序的子数组段,然后再 将它们排序成长度为 8 的排好序的子数组段,如此继续下去,直至整个数组排好序。 按此思想,消去递归后的自底向上合并排序算法 %&’(&)"’*,9 可描述如下: !"#$ %&’(&)"’*,9(-*&% .[],#0* 1,#0* ’) { 2 2 #0* #,%; 2 2 4"’(% "5;% $’ &1;% "% #%) 2 2 2 4"’(# "1;# $"’ &%;# # "% #%) 2 2 2 2 %&’(&(.,#,# #% &5,%#0#(# #% #% &5,’)); } !" #" %$ 自然合并排序 自然合并排序是前面讨论的自底向上合并排序算法 %&’(&)"’*,9 的一个变形。在前述自底 向上合并排序算法中,第一步合并相邻长度为 5 的子数组段,这是因为长度为 5 的子数组段是已 !""第 : 章2 排序与选择 排好序的。事实上,对于初始给定的数组 !,通常存在多个长度大于 " 的已自然排好序的子数组 段。例如,数组 ! 中元素为{#,$,%,&,",’,(,)}时,自然排好序的子数组段有{#,$},{%,&}, {",’,(}和{)}。用 " 次对数组 ! 的线性扫描就足以找出所有已排好序的子数组段。然后将相 邻的排好序的子数组段两两合并,构成更大的排好序的子数组段。对上面的例子,经一次合并后 得到 ) 个合并后的子数组段{%,#,&,$}和{",),’,(}。继续合并相邻排好序的子数组段,直至整 个数组已排好序。上面这 ) 个数组段再合并后就得到{",),%,#,’,(,&,$}。 上述思想就是自然合并排序算法的基本思想。在通常情况下,按此方式进行合并排序所需 的合并次数较少。例如,对于所给的 ! 元素数组已排好序的极端情况,自然合并排序算法不需要 执行合并步骤,而算法 *+,-+./,012 需要执行 3/-! 次合并。因此,在这种情况下,自然合并排 序算法需要 "(!)时间,而算法 *+,-+./,012 需要 "(!3/-!)时间。 !" #" $% 链表结构的合并排序算法 对于用链表结构表示的输入序列,合并排序算法的思想是类似的。此时,存放待排序元素的 结点结构定义为: 045+6+7 .0,890 :/6+ !3;:<; 045+6+7 .0,890 :/6+ {=0+* +3+*+:0;3;:< :+>0;}?/6+; 算法 *+,-+(!,@)将 ) 个有序链表 ! 和 @ 合并为一个新的有序链表。 3;:< *+,-+(3;:< !,3;:< @) { A ?/6+ B+!6; A 3;:< 9 "CB+!6; A DB;3+(! CC @) A A ;7(3+..(! &%+3+*+:0,@ &%+3+*+:0)) A A A {9 &%:+>0 "!;9 "!;! "! &%:+>0;} A A +3.+ A A A {9 &%:+>0 "@;9 "@;@ "@ &%:+>0;} A 9 &%:+>0 "(!! )?@ :!; A ,+08,: B+!6E :+>0; } 在算法的划分阶段,需要将一个链表划分为大小相同的 ) 个子链表,然后递归地对 ) 个子链 表排序。在算法的合并阶段,用算法 *+,-+ 将排好序的子链表合并为整个已排序的链表。 3;:< *+,-+./,0(3;:< 9) !"" 数据结构与算法 { ! "#$% &,’; ! #((!) &%$*+,)-*,.-$ ); ! & ");’ ") &%$*+,; ! /0#"*(’ 11 ’ &%$*+,) ! ! {) ") &%$*+,;’ "’ &%$*+, &%$*+,;} ! ’ ") &%$*+,;) &%$*+, "2; ! -*,.-$ 3*-4*(3*-4*56-,(&),3*-4*56-,(’)); } 类似于自底向上合并排序算法 3*-4*56-,78,链表结构的自底向上合并排序算法,先将输入 序列拆成单个元素的链表,并依序存放在一个队列 9 中。然后反复用合并算法 3*-4* 合并队列 9 队首的 : 个已排序的有序子链表,并将合并后的有序子链表存入队列 9 的队尾。这个过程一直 继续到队列 9 中只剩下一个有序链表时为止。最后的这个有序链表即为算法的输出序列。 "#$% 3*-4*56-,("#$% ,) { ! "#$% .; ! ;.*.* 9; ! (6-(9 ";.*.*<$#,();,;, ".) ! ! {. ", &%$*+,;, &%$*+, "2;=$,*-;.*.*(,,9);} ! , ">*"*,*;.*.*(9); ! /0#"*(!;.*.*=3?,@(9)) ! ! {=$,*-;.*.*(,,9);, "3*-4*(>*"*,*;.*.*(9),>*"*,*;.*.*(9));} ! -*,.-$ ,; } AB C! 线性时间排序算法 上面讨论的排序算法有一个共同的特点,即它们用于确定排序结果的主要运算是输入元素 间的比较运算。这类排序算法称为基于比较的排序算法。基于比较的排序算法的计算时间下界 是 !(!"64!)。本节讨论以数字和地址计算为主要运算的排序算法。由于这些算法已不是基于 比较的排序算法,故 !(!"64!)计算时间下界对它们已不适用。事实上,它们都可以在线性时间 内完成排序任务。 !""第 A 章! 排序与选择 !" #" $% 计数排序 计数排序算法的基本思想是对每一个输入元素 !,确定输入序列中键值小于 ! 的元素个数。 一旦有了这个信息,就可以将 ! 直接存放到最终的输出序列的正确位置上。例如,如果输入序列 中有 !" 个元素的键值小于 !,则 ! 就应存放在第 !# 个输出位置上。当然,如果有多个元素具有 相同的键值时,不能将这些元素放在同一个输出位置上,因此,上述方案还要作适当的修改。 为了便于讨论,在下面的计数排序算法中,假设输入的 " 个元素存放在数组 $[%:& ’ !]中; 输出的排序结果存放在数组 ([%:& ’ !]中。数组 # 和 $ 中的每个元素均为 % ) % 之间的一个整 数。算法中还用到一个辅助数组 *[%:+]用于对输入元素进行计数。 计数排序算法描述如下: ,-./0&. + !%%%% 120- *23&45264(0&4 $[],0&4 ([],0&4 &) { 7 0&4 0,*[+ #!]; 7 /26(0 "%;0 $"+;0 ##)*[0]"%; 7 /26(0 "%;0 $&;0 ##)*[$[0]]##; 7 !!*[0]中存放的是 $ 中键值等于 0 的元素个数 !! 7 /26(0 "!;0 $"+;0 ##)*[0] # "*[0 &!]; 7 !!*[0]中存放的是 $ 中键值小于或等于 0 的元素个数 !! 7 /26(0 "&;0 %%;0 &&){ 7 ([*[$[0 &!]]&!]"$[0 &!]; 7 *[$[0 &!]]&&; 7 } } 算法 *23&45264 对数组 & 初始化后,顺序检查每个输入元素,如果某个输入元素的值为 0,则 *[0]增 !。因此,在检查结束后,*[0]中存放数组 # 中值等于 ’ 的输入元素个数,’ 8 %,!,9,⋯ , %。随后,对每个 ’ 8 !,9,⋯ ,%,统计值小于或等于 ’ 的输入元素个数。最后,将每个元素 $[0]存 放到输出数组 $ 中与其相应的最终位置上。如果所有 " 个元素的值都不相同,则共有 *[$[0]] 个元素的值小于或等于 $[0],而小于 $[0]的元素有 *[$[0]]’ ! 个,因此,*[$[0]]即为 $[0]在 输出数组 ( 中的正确位置。当输入元素有相同的值时,每将一个 $[0]存放到数组 $ 时,*[$[0]] 就减 !,使下一个值等于 $[0]的元素存放在输出数组 $ 中存放元素 $[0]的前一个位置中。 计数排序算法的计算时间复杂性很容易分析。其中,对数组 & 初始化需要 ((%)时间。顺 序检查每个输入元素需要 ((")时间。对每个 ’ 8 !,9,⋯ ,% 统计值小于或等于 ’ 的输入元素个 数需要 ((%)时间。最后,将每个元素输出到数组 $ 中需要 ((")时间。这样,整个算法所需的 计算时间为 ((% ) ")。当 % 8 ((")时,算法的计算时间复杂性为 ((")。 !"" 数据结构与算法 从上面的讨论可以看出,计数排序算法没有比较元素大小,它利用元素的值来确定其正确的 输出位置。因此,计数排序算法不是一个基于比较的排序算法,从而它的计算时间下界不再是 !(!!"#!)。另一方面,计数排序算法之所以能取得线性计算时间上界,是因为对元素的取值范围 作了一定限制,即 " $ #(!)。如果 " $ !% ,!& ,⋯ ,就得不到线性时间上界。 计数排序算法的另一个重要性质是,在输入和输出序列中,具有相同值元素的相对次序不 变。换句话说,计数排序算法是一个稳定的排序算法。 !" #" $% 桶排序 与计数排序类似的一个线性时间排序算法是桶排序算法。桶排序算法的基本思想是:设置 若干个桶,将键值等于 % 的元素全部装入第 % 个桶中。然后,按桶的顺序将桶中元素顺序连接 起来。 由于每个桶中元素键值相同,可以将第 % 个桶看作是键值为 % 的元素组成的一个表。用数组 ’"((") 表示桶底,’"((")[*]指向第 % 个桶中第一个元素。用数组 ("+ 表示桶顶,("+[*]指向第 % 个 桶中最后一个元素。这样很容易按桶的顺序将桶中元素顺序连接在一起。 下面给出桶排序算法。该算法假定输入序列是以单链表形式给出,桶排序算法 ’*,-".( 返回 排序后的单链表。元素键值上界为 "。 !*,/ ’*,-".((!*,/ 0*.-() { 1 *,( ’;1 !!桶下标 !! 1 !*,/ ’"((")[) #2]; 1 !*,/ ("+[) #2]; 1 !*,/ + "3; 1 0".(’ "3;’ $");’ ##)’"((")[’]"3;1 !!桶初始化 !! 1 0".(;0*.-(;0*.-( "0*.-( &%,45(){1 !!将元素装入桶中 !! 1 1 ’ "0*.-( &%*(4); 1 1 *0(’"((")[’]){1 !!桶非空 !! 1 1 1 ("+[’]&%,45( "0*.-(; 1 1 1 ("+[’]"0*.-(;} 1 1 4!-4 ’"((")[’]"("+[’]"0*.-(;1 !!桶空!! 1 1 } 1 !!按桶的顺序将桶中元素顺序连接在一起 !! 1 0".(’ "3;’ $");’ ##) 1 1 *0(’"((")[’]){1 1 1 1 1 1 1 !!桶非空 !! 1 1 1 *0(+)+ &%,45( "’"((")[’];1 !!不是第一个非空桶 !! 1 1 1 4!-4 0*.-( "’"((")[’];1 1 1 !!第一个非空桶 !! 1 1 1 + "("+[’];} !""第 6 章1 排序与选择 ! "#($)$ &%%&’( "); ! *&(+*% #"*,(; } 桶排序算法所需的计算时间与计数排序算法所需的计算时间大致相同,它们都需要 !(" # $)计算时间。初始化空桶需要 !(")时间。将所有输入元素装入桶中共需 !($)时间。将桶中 元素依序连接共需 !(")时间。于是,整个桶排序算法共用 !(" # $)时间。与计数排序算法类 似,如果 " - !($),则桶排序算法只需要 !($)计算时间。 ./ 0! 中位数与第 % 小元素 下面讨论与排序问题类似的元素选择问题。元素选择问题的一般提法是:给定线性序集中 $ 个元素和一个整数 %,1"%"$,要求找出这 $ 个元素中第 % 小的元素,即如果将这 $ 个元素依 其线性序排列时,排在第 % 位的元素即为要找的元素。当 % - 1 时,就是要找最小元素;当 % & $ 时,就是要找最大元素;当 % &($ # 1)’ 2 时,称为找中位数。 在某些特殊情况下,很容易设计出解选择问题的线性时间算法。例如,找 $ 个元素的最小元 素和最大元素显然可以在 !($)时间完成。如果 %"$ ’ 345$,通过堆排序算法可以在 !( $ # %345$)& !($)时间内找出第 % 小元素。当 %$$ ( $ ’ 345$ 时也一样。 !" #" $% 平均情况下的线性时间选择算法 一般的选择问题,特别是中位数的选择问题似乎比找最小元素要难。但事实上,从渐近阶的 意义上看,它们是一样的。一般的选择问题也可以在 !($)时间内得到解决。下面讨论解一般 的选择问题的一个分治算法 *6%748,&3&9(。该算法实际上是模仿快速排序算法设计出来的。其 基本思想也是对输入数组进行递归划分。与快速排序算法不同的是,它只对划分出的子数组之 一进行递归处理。 算法 *6%748,&3&9( 用到在随机快速排序算法中讨论过的随机划分函数 *6%748$6*("("4%。因 此,划分 是 随 机 产 生 的。由 此 导 致 算 法 *6%748,&3&9( 也 是 一 个 随 机 化 的 算 法。要 找 数 组 6[):% : 1]中第 % 小元素只要调用 *6%748,&3&9((6,),% : 1,;)即可。具体算法可描述如下: <(&8 *6%748,&3&9((<(&8 6[],"%( 3,"%( *,"%( ;) { ! "%( ",=,$; ! "#(* $"3)*&(+*% 6[*]; ! " "*6%748$6*("("4%(6,3,*); ! = "" &3 #1; ! "#(= "";)*&(+*% 6["]; !"" 数据结构与算法 ! "#($ %%)&’()&* &+*,-./’0’1((+,0," &2,%); ! ’0/’ &’()&* &+*,-./’0’1((+," #2,&,% &$); } 在算法 &+*,-./’0’1( 中执行 &+*,-.3+&("("-* 后,数组 +[0:&]被划分成 4 个子数组 +[0:"]和 + [" 5 2:&],使得 +[0:"]中每个元素都不大于 +[" 5 2:&]中每个元素。接着算法计算子数组 +[0:"] 中元素个数 !。如果 ""!,则 +[0:&]中第 " 小元素落在子数组 +[0:"]中。如果 " # !,则要找的第 " 小元素落在子数组 +[" 5 2:&]中。由于此时已知道子数组 +[0:"]中元素均小于要找的第 " 小元 素,因此,要找的 +[0:&]中第 " 小元素是 +[" 5 2:&]中的第 " $ ! 小元素。 容易看出,在最坏情况下,算法 &+*,-./’0’1( 需要 !(%4 )计算时间。例如在找最小元素时, 总是在最大元素处划分。尽管如此,该算法的平均性能很好。 由于随机划分函数 &+*,-.3+&("("-* 使用了一个随机数产生器 &+*,-.",它能随机地产生& ’ ( 之间的一个随机整数,因此,&+*,-.3+&("("-* 产生的划分基准是随机的。在这个条件下,可以证 明,算法 &+*,-./’0’1( 可以在 )(%)平均时间内找出 % 个输入元素中的第 " 小元素。 消除 &+*,-./’0’1( 尾递归的算法如下: 6(’. &+*,-./’0’1((6(’. +[],"*( 0,"*( &,"*( %) { ! ! "*( ",$; ! ! 78"0’(& %0) ! ! {" "&+*,-.3+&("("-*(+,0,&); ! ! ! $ "" &0 #2; ! ! ! "#($ ""%)&’()&* +["]; ! ! ! "#($ %%)& "" &2; ! ! ! ’0/’ {0 "" #2;% & "$;} ! ! } ! ! &’()&*((& $")?+[0]:+[&]); } !" #" $% 最坏情况下的线性时间选择算法 下面来讨论一个类似于 &+*,-./’0’1( 但可以在最坏情况下用 )(%)时间可以完成选择任务的 算法 /’0’1(。如果能在线性时间内找到一个划分基准,使得按这个基准所划分出的 4 个子数组的长 度都至少为原数组长度的 " 倍(9 : " :2 是某个正常数),那么在最坏情况下用 )(%)时间就可以完 成选择任务。例如,若 " ;< = 29,算法递归调用所产生的子数组的长度至少缩短 2 = 29。所以,在最 坏情况下,算法所需的计算时间 *(%)满足递归式 *(%)"*(<%+ 29), )(%)。由此可得 *(%)- ) (%)。 !""第 > 章! 排序与选择 可以按以下步骤来寻找这样的一个好的划分基准: ! 将 ! 个输入元素划分成 ! " ! 个组,除可能有一个组不是 ! 个元素外,每组 ! 个元素。 用任意一种排序算法,将每组中的元素排好序,并取出每组的中位数,共 ! " ! 个。 " 递归调用 "#$#%& 来找出这 ! " ! 个元素的中位数。如果 ! " ! 是偶数,就找它的 ’ 个中 位数中较大的一个。然后以这个元素作为划分基准。 图 () * 是上述划分策略的示意图,其中 ! 个元素用小圆点来表示,空心小圆点为每组元素的 中位数。中位数的中位数 # 在图中标出。图中所画箭头是由较大元素指向较小元素。 图 () *+ 选择划分基准 只要等于基准的元素不太多,利用这个基准来划分的 ’ 个子数组的大小就不会相差太远。 为了简化问题,先设所有元素互不相同。在这种情况下,找出的基准 # 至少比 ,(! $ !)" *- 个 元素大,因为在每一组中有 ’ 个元素小于本组的中位数,而 ! " ! 个中位数中又有(! $ !)" *- 个小于基准 #。同理,基准 # 也至少比 ,(! $ !). *- 个元素小。而当 !$/! 时,,(! $ !). *- $ ! . 0。所以按此基准划分所得的 ’ 个子数组的长度都至少缩短 * . 0。这一点至关重要。据此,可 以给出算法 "#$#%& 如下: 1 "#$#%&(1 3[],45& $,45& 6,45& 7) { + 45& 4,8,",&; + 1 9; + 4:(6 &$ $/){ + + !!用某个简单排序算法对数组 3[$:6]排序 !! + + ;<;;$#(3,$,6); + + " "$ #7 &*; + + 4:(" %6)" "6; + + 4:(" $$)" "$; + + 6#&<65 3["]; + + } + !!将 3[$ #!!4]至 3[$ #!!4 #0]的第 , 小元素与 3[$ #4]交换位置 !! + !!找中位数的中位数,6 &$ &0 即上面所说的 5 &! !! + :=6( 4 "-;4 $"(6 &$ &0)!!;4 ##) !"# 数据结构与算法 ! { ! ! " "# #$!%; ! ! & "" #’; ! ! ()((#*(+,",&); ! ! ",+-(+[# #%],+[" #.]); ! } ! / ""*#*0&(+,#,# #(1 &# &’)!$,(1 &# #2)!34); ! % "-+1&%&%567(+,#,1,/); ! 8 "% &# #3; ! %9(8 "":)1*&)16 +[%]; ! %9(8 %:)1*&)16 "*#*0&(+,#,% &3,:); ! *#"* 1*&)16 "*#*0&(+,% #3,1,: &8); } 为了分析算法 "*#*0& 的计算时间复杂性,设 ! " # $ % & 3,即 ! 为输入数组的长度。算法的递 归调用只有在 !$;$ 时才执行。因此,当 ! < ;$ 时算法 "*#*0& 所用的计算时间不超过一个常数 ’3 。找到中位数的中位数 ( 后,算法 "*#*0& 以 ( 为划分基准调用函数 -+1&%&%567 对数组 +[#:1]进 行划分,这需要 )(!)时间。算法 "*#*0& 的 951 循环体行共执行 ! = $ 次,每一次需要 )(3)时间。 因此,执行 951 循环共需 )(!)时间。 设对 ! 个元素的数组调用 "*#*0& 需要 *(!)时间,那么找中位数的中位数 ( 至多用了*(! + $) 的时间。上面已证明按照算法所选的基准 ( 进行划分所得到的 . 个子数组分别至多有 >! = ’ 个 元素。所以无论对哪一个子数组调用 "*#*0& 都至多用了 *(>! + ’)的时间。 总之,可以得到关于 *(!)的递归式 *(!)" , , , , , ’3 , , , , , , ! - ;$ ’. ! & *(! + $)& *(>! + ’) !$ { ;$ 解此递归式可得 *(!)" )(!)。 由于将每一组的大小定为 $,并选取 ;$ 作为是否作递归调用的分界点。这两点保证了*(!) 的递归式中 . 个自变量之和 ! = $ ? >! = ’ @ 3A! = .4 @ !!,4 < ! < 3。这是使 *(!)" )(!)的关键 之处。当然,除了 $ 和 ;$ 之外,还可以有其他选择。 在算法 "*#*0& 中,假设所有元素互不相等,这是为了保证在以 ( 为划分基准调用函数 -+1&%B &%567 对数组 +[#:1]进行划分之后,所得到的 . 个子数组的长度都不超过原数组长度的 > = ’。当 元素可能相等时,应在划分之后加一个语句,将所有与基准 ( 相等的元素集中在一起,如果这样 的元素的个数 .$3,而且 /"0"/ & . $ 3 时,就不必再递归调用,只要返回 +[%]即可。否则最后 一行改为调用 "*#*0&(% ? C ? 3,1,: D 8 D C)。 !"!第 2 章! 排序与选择 !" !# 应# # 用 带权中位数问题:对于 ! 个带有正权 "$ ,"% ,⋯ ,"! ,且 # ! # $ $ "# $ $ 的互不相同的元素 %$ ,%% , ⋯ ,%! ,其带权中位数 %& 满足 #%# ’ %& "# " $ % #%# ( %& "# " { $ % # # 下面讨论用本章介绍的排序和选择算法解带权中位数问题。 设给定的 ! 个元素存储在数组 ) 中,每个元素的结构类型为: &’()*)+ ,&-./& 01*) {*1.23) )3)4)0&;*1.23) 5)678&;}91*); &’()*)+ 91*) :&)4; ;*)+60) <)’(=)(=" )3)4)0&) ;*)+60) 3),,(=,>)(<)’(=)$<)’(>)) ;*)+60) ,5?((=,>){:&)4 & "=;= ">;> "&;} 其中,)3)4)0& 存储元素,5)678& 存储元素相应的权值。 将数组 ?[@:0 A $]从小到大排序。然后对排好序的序列作一次线性扫描即可找出带权中 位数。 :&)4 54)*6?0$(:&)4 ?[],60& 0) { # 60& 6 "$; # *1.23) /; # ,1-&(?,@,0 &$); # / "?[@]" 5)678&; # 5863)(6 $0 BB / #?[6]" 5)678& $C @" D)/ #"?[6 ##]" 5)678&; # -)&.-0 ?[6 #$]; } 在算法 54)*6?0 中,,1-&(?,@,0 A $)是对数组 ? 进行排序的耗时 *(!317!)的算法。由于算 法的主要计算量在于排序算法,故算法 54)*6?0 所需的计算时间为 *(!317!)。 利用一个线性时间选择算法 ,)3)/& 和分治策略,可设计一个在最坏情况下用 *(!)时间求 ! 个元素的带权中位数的算法如下: !!" 数据结构与算法 !"#$ %$#&’()(!"#$ ([],’)" *,’)" +,&,-.*# /0,&,-.*# /1) { 2 2 ’)" ’,$; 2 &,-.*# 30 "4,31; 2 ’5(* ""+)+#"-+) ([* #0]; 2 $ "/#*#3"((,*,+,(+ &* #0)!1); 2 5,+(’ "*;’ $$;’ ##)30 # "([’]6 %#’78"; 2 31 "/0 #/1 &30 &([$]6 %#’78"; 2 ’5(30 %/0)+#"-+) %$#&’()((,*,$ &0,/0,/1 &31 &([$]6 %#’78"); 2 ’5(31 %/1)+#"-+) %$#&’()((,$ #0,+,/0 &30 &([$]6 %#’78",/1); 2 +#"-+) ([$]; } 函数调用 %$#&’()((,4,) 9 0,46 :,46 :)返回数组元素 ([4:) 9 0]的带权中位数。 上述算法 %$#&’() 所需的空间显然为 !(")。由于采用了分治技术,/#*#3" 每次取 ([*:+]的 中位数,并将 # 划分为规模大致相同的两部分 ([*:$ 9 0]和 ([$ ; 0:+]使得 ([’]6 #*#$#)""( [$]6 #*#$#)",$"%"& ’ 0;([$]6 #*#$#)""([<]6 #*#$#)",& ( 0")"*。算法 /#*#3" 在最坏情况下 所需的计算时间均为 !(* ’ $)。因此,算法 %$#&’() 所需计算时间 +(")满足如下递归式 +("), +(" - 1)( !(") 2 2 解此递归式可得 +("), !(")。 本 章 小 结 本章讲授的主题是排序算法。在明确了排序问题的提法及其实质后,介绍了在实践中常用 的简单排序算法,如冒泡排序算法、插入排序算法和选择排序算法的设计思想与分析方法。快速 排序算法是一个效率高且实用性强的排序算法。本章用较大篇幅介绍了快速排序算法的基本设 计思想及其多方面的改进,借此展示算法设计中精益求精的设计思想和策略。合并排序算法是 另一个用分治和递归策略设计算法的经典例子。本章对合并排序算法也展开了深入细致的讨 论。计数排序算法和桶排序算法是两个典型的线性时间排序算法。本章通过对这两个算法的讨 论阐述了线性时间排序算法的设计思想与分析方法,并进一步讨论线性时间排序算法与基于比 较的排序算法的主要差别和适用范围。本章还介绍了与排序问题类似的选择问题及相应的算 法。从平均情况和最坏情况两个不同侧面研究算法的设计思想及实现方法。 习2 2 题 !" #$ 试修改冒泡排序算法,使得当输入序列已排好序时冒泡排序算法只需 !(")时间。 !" %$ 试修改选择排序算法,使得当输入序列已排好序时选择排序算法只需 !(")时间。 !" &$ 如何修改快速排序算法才能使其将输入元素按非增序排序? !"#第 = 章2 排序与选择 !" #$ 当数组 ! 的元素已排成非增序时,快速排序算法需要多少计算时间? !" %$ 试证明当数组 ! 的所有元素的键值都相同时,快速排序算法需要的计算时间为 !("!"#")。 !" !$ 试用 $%&!’ 循环消去快速排序算法中的尾递归,并比较消去尾递归前后算法的效率。 !" &$ 试说明如何修改快速排序算法,使它在最坏情况下的计算时间为 #("!"#")。 !" ’$ 试设计一个 #(")时间算法,使之能产生数组 ([):* + ,]元素的一个随机排列。 !" ($ 在执行随机快速排序算法时,在最坏情况下调用 -(*."/& 多少次,在最好情况下又怎样? !" )*$ 设子数组 ([):0]和 ([0 1 ,:* + ,]已排好序()"0"* + ,)。试设计一个合并这两个子数组为排好 序的数组 ([):* + ,]的算法。要求算法在最坏情况下所用的计算时间为 #("),且只用到 #(,)的辅助空间。 !" ))$ 如果在合并排序算法的分割步骤中,将数组 ([):* + ,]划分为 !"2 个子数组,每个子数组中有 #(!")个元素。然后递归地对分割后的子数组进行排序,最后将所得到的 !"2 个排好序的子数组合并成所要 求的排好序的数组 ([):* + ,]。设计一个实现上述策略的合并排序算法,并分析算法的计算复杂性。 !" )+$ 对所给元素存储于数组中和存储于链表中两种情形,写出自然合并排序算法。 !" ),$ 设 $, ,$3 ,⋯ ,$% 是整数集合,其中每个集合 $&(,"&"%)中整数取值范围是 , 4 ",且 # % & ’ , ( $& ( ’ " ,试 设计一个算法在 #(")时间内将 $, ,$3 ⋯ ,$% 分别排序。 !" )#$ 由字母 ( 4 5 所组成的字符串的一个集合中,各字符长度之和为 ",怎样用 #(")时间将这个集合中所 有字符串进行排序?注意,如果集合中的每个字符串都是有界的,则可以用桶排序算法,但这里可能存在非常长 的字符串 。 !" )%$ 在最坏情况下的线性时间选择算法中输入元素被划分为 6 个一组,如果将它们划分为 7 个一组,该 算法在最坏情况下仍然是线性时间算法吗,划分成 8 个一组又怎样? !" )!$ 给定一个由 " 个互不相同的数组成的集合 $,及一个正整数 %"",试设计一个 #(")时间算法找出 $ 中最接近 $ 的中位数的 % 个数。 !" )&$ 试设计一个算法,用 #(" ) %)时间对介于[,:%]之间的 " 个整数进行预处理,使得经过预处理后,对 于任意给定的区间[!:*],能够在 #(,)时间内计算出这 " 个整数中有多少个数落在所给的区间中。 !" )’$ 给定单位圆中 " 个点,假设这 " 个点在单位圆中是均匀分布的。试设计一个 #(")时间算法对这 " 个点依其到圆心的距离进行排序。 !" )($ 设 " 个不同的整数排好序后存于数组 +[):" , ,]中。若存在一个下标 &,,"&"",使得 +(&)’ &,设计 一个有效算法找到这个下标。要求算法在最坏情况下的计算时间为 #(!"#")。 !" +*$ 在一个由元素组成的表中,出现次数最多的元素称为众数。试写一个寻找众数的算法,并分析其计 算复杂性。 !" +)$ 邮局位置问题定义为:已知 " 个点 -, ,-3 ,⋯ ,-" 以及与它们相联系的权 ., ,.3 ,⋯ ,." ,要求确定一点 -(- 不一定是 " 个输入点之一),使和式 # " & ’ , .& /(-,-& )达到最小,其中 /(!,*)表示 ! 与 * 之间的距离。 ! 试论证带权中位数是一维邮局问题的最优解。此时 /(!,*)’ ( ! , *( 。 " 若为二维的情形如何找最优解? !" ++$ 某石油公司计划建造一条由东向西的主输油管道。该管道要穿过一个有 " 口油井的油田。从每口 油井都要有一条输油管道沿最短路径(或南或北)与主管道相连。如果给定 " 口油井的位置,即它们的 0 坐标和 1 坐标,应如何确定主管道的最优位置,即使各油井到主管道之间的输油管道长度总和最小的位置?证明可在 线性时间内确定主管道的最优位置。 !"# 数据结构与算法 书书书 第 ! 章" 树 学习目标 ! 理解树的定义和与树相关的结点、度、路径等术语。 ! 理解树是一个非线性层次数据结构。 ! 掌握树的前序遍历、中序遍历和后序遍历方法。 ! 了解树的父结点数组表示法。 ! 了解树的儿子链表表示法。 ! 了解树的左儿子右兄弟表示法。 ! 理解二叉树和 !"# 二叉树的概念。 ! 了解二叉树的顺序存储结构。 ! 了解二叉树的结点度表示法。 ! 掌握用指针实现二叉树的方法。 ! 理解线索二叉树结构及其适用范围。 $% &’ 树 的 定 义 树是一个具有层次结构的集合,它在客观世界中广泛存在。例如人类社会的族谱以及各种 社会组织机构等,都可以用树来形象表示。树在计算机科学的许多领域中有广泛应用,人们用树 进行电路分析;用树表示数学公式的结构;在数据库系统中,用树组织信息;在编译过程中,用树 表示源程序的句法结构。在后续章节中还会遇到许多特殊类型的树。本章重点讨论树的一些基 本概念,以及作为抽象数据类型的树的一般操作和一些常用的表示树的数据结构,这些数据结构 能有效地实现树的操作。 树是由一个集合以及在该集合上定义的一种层次关系构成的。集合中的元素是树的结点, 结点间的关系为父子关系。树结点之间的父子关系建立了树的层次结构。在这种层次结构中, 有一个结点具有特殊的地位,这个结点称为该树的根结点,或简称为树根。下面形式地给出树的 递归定义: ! 单个结点是一棵树,树根就是该结点本身。 " 设 !! ,!" ,⋯ ,!" 是树,它们的根结点分别为 #! ,#" ,⋯ ,#" 。用一个新结点 # 作为 #! ,#" , ⋯ ,#" 的父亲,则得到一棵新树。结点 # 就是新树的根。结点 #! ,#" ,⋯ ,#" 称为一组兄弟结点, 它们都是结点 # 的儿子结点。还称 !! ,!" ,⋯ ,!" 为结点 # 的子树。 为方便起见,将空集合也看作是树,称为空树,用"来表示。空树中没有结点。 树中的结点与表中的元素类似,它可以具有任何一种类型。在用图来表示树时,常用一个圆 圈表示一个结点,并在圆圈中标一个字母或一个字符串或一个数作为该结点的名字,以便与其他 结点区别。 树的递归定义刻画了树的固有特性,即一棵树是由若干棵子树构成的。 图 #$ !% 树的层次结构 图 #$ ! 中所示的一棵树,由结点的有限集 ! &{$,%,&, ’,(,),*,+,,,-}所构成。其中 $ 是根结点。! 中其余结点 分成 ’ 个互不相交的子集 !! &{%,(,),,,-},!" &{&},!’ & {’,*,+}。!! 、!" 和 !’ 是根 $ 的 ’ 棵子树,且本身又都是一 棵树。 下面给出树结构中的一些基本概念和常用术语,其中许 多术语借用了族谱树中的一些习惯用语。 ! 一个结点的儿子结点个数称为该结点的度。一棵树 的度是指该树中结点最大度数。 " 树中度为零的结点称为叶结点或终端结点。 # 树中度不为零的结点称为分枝结点或非终端结点。除根结点外的分枝结点统称为内部 结点。 例如,在图 #$ ! 中,结点 $、% 和 ( 的度分别 ’,",(。其中 $ 为根结点,% 为内部结点,( 为叶 结点,树的度为 ’。 $ 如果存在树中的一个结点序列 "! ,"" ,⋯ ,". ,使得结点 "/ 是结点 "/ ) ! 的父结点(!#/ * .), 则称该结点序列是树中从结点 "! 到结点 ". 的一条路径或道路。称这条路径的长度为 . + !,它是 该路径所经过的边(即连接 " 个结点的线段)的数目。树中任一结点有一条到其自身的长度为 零的路径。例如,在图 #$ ! 中,结点 $ 到结点 , 有一条路径 ,-./,它的长度为 ’。 % 如果在树中存在一条从结点 " 到结点 0 的路径,则称结点 " 是结点 0 的祖先,也称结点 0 是结点 " 的子孙或后裔。例如在图 #$ ! 中,结点 ) 的祖先有 $、% 和 ) 自己,而它的子孙包括 它自己和 ,、-。注意,任一结点既是它自己的祖先也是它自己的子孙。 & 将树中一个结点的非自身的祖先和子孙分别称为该结点的真祖先和真子孙。在一棵树 中,树根是惟一没有真祖先的结点。叶结点是没有真子孙的结点。子树是树中某一结点及其所 有真子孙组成的一棵树。 ’ 树中一个结点的高度是指从该结点到各叶结点的最长路径的长度。树的高度是指根结 点的高度。例如图 #$ ! 中的结点 %、& 和 ’ 的高度分别为 "、( 和 !,而树的高度与结点 $ 的高度 相同为 ’。 !"# 数据结构与算法 ! 从树根到任一结点 ! 有惟一的一条路径,称这条路径的长度为结点 ! 的深度或层数。根 结点的深度为 !,其余结点的深度为其父结点的深度加 "。深度相同的结点属于同一层。例如, 在图 #$ " 中,结点 " 的深度为 !;结点 #、$ 和 % 的深度为 ";结点 &、’、(、) 的深度为 %;结点 * 和 + 的深度为 &。在树的第 % 层的结点有 &、’、( 和 );树的第 ! 层只有一个根结点 "。 " 树的定义在某些结点之间确定了父子关系,这种关系又延拓为祖先和子孙关系。但是树 中的许多结点之间仍然没有这种关系。例如兄弟结点之间就没有祖先或子孙关系。如果在树的 每一组兄弟结点之间定义一个从左到右的次序,则得到一棵有序树;否则称为无序树。设结点 ! 的所有儿子按其从左到右的次序排列为 !% ,!% ,⋯ ,!, ,则称 !" 是 ! 的最左儿子,或简称左儿子, 并称 !- 是 !- ’ " 的右邻兄弟,或简称右兄弟(- ( %,&,⋯ ,,)。 图 #$ %) 两棵不同的有序树 图 #$ % 中的两棵树作为无序树是相同的,但作为 有序树是不同的,因为结点 . 的两个儿子在两棵树中 的左右次序是不同的。 兄弟结点之间的左右次序关系还可延拓:如果 . 与 / 是兄弟,并且 . 在 / 的左边,则规定 . 的任一子孙 都在 / 的任一子孙的左边。 # 森林是 0(0$!)棵互不相交的树的集合。如果删去一棵树的树根,留下的子树就构成 了一个森林。当删去的是一棵有序树的树根时,留下的子树也是有序的,这些树组成一个树表, 在这种情况下,称这些树组成的森林为有序森林或果园。 #$ %) 树 的 遍 历 树的遍历是树的一种重要的运算。所谓遍历是指对树中所有结点的系统的访问,即依次对 树中每个结点访问一次且仅访问一次。树的 & 种最重要的遍历方式分别称为前序遍历、中序遍 历和后序遍历。以这 & 种方式遍历一棵树时,若按访问结点的先后次序将结点排列起来,就分别 得到树中所有结点的前序列表,中序列表和后序列表。相应的结点次序就分别称为结点的前序、 中序和后序。这 & 种遍历方式可递归地定义如下: $ 如果 1 是一棵空树,那么对 1 进行前序遍历、中序遍历和后序遍历都是空操作,得到的列 表为空表。 % 如果 1 是一棵单结点树,那么对 1 进行前序遍历、中序遍历和后序遍历都只访问这个单 结点。这个结点本身就是得到的相应的列表。 图 #$ &) 树 1 否则,设 1 如图 #$ & 所示,它以 ! 为树根,树根的子树从左到右依次 为 1" ,1% ,⋯ ,1, ,那么 $ 对 1 进行前序遍历是先访问树根 !,然后依次前序遍历 1" ,1% ,⋯ , 1, ,即前序遍历 1" ,然后前序遍历 1% ,⋯ ⋯ ,最后前序遍历 1, 。 % 对 1 进行中序遍历是先中序遍历 1" ,然后访问树根 !,接着依次对 1% ,⋯ ,1, 进行中序遍历。 !"#第 # 章) 树 ! 对 ! 进行后序遍历是先依次对 !! ,!" ,⋯ ,!" 进行后序遍历,最后访问树根 #。 例如对图 #$ ! 中的树进行前序遍历、中序遍历和后序遍历得到的列表分别为: 前序列表:$% %% &% ’% (% )% *% +% ,% - 中序列表:&. %. (. ’. ). $. *. ,. +. - 后序列表:&. (. ). ’. %. *. ,. -. +. $ 下面介绍一种方法可以非递归方式产生 & 种遍历的结点列表。 图 #$ ’% 树的遍历 设想从树根出发,依逆时针方向沿树的外缘绕行(例如 围绕图 #$ ! 中的树绕行的路线如图 #$ ’ 所示)。绕行途中可 能多次经过同一结点。如果按第一次经过的时间次序将各 个结点列表,就可以得到前序列表;如果按最后一次经过的 时间次序列表,也就是在即将离开某一结点走向其父结点时 将该结点列出,就得到后序列表。为了产生中序列表,要将 叶结点与内部结点加以区别。叶结点在第一次经过时列出, 而内部结点在第 " 次经过时列出。 在上述 & 种不同次序的列表方式中,各树叶之间的相对 次序是相同的,它们都按树叶之间从左到右的次序排列。& 种列表方式的差别仅在于内部结点之间以及内部结点与树叶之间的次序有所不同。 对一棵树进行前序列表或后序列表有助于查询结点间的祖先和子孙关系。假设结点 # 在后 序表中的序号为 ()*+),-.,(/),称这个整数为结点 # 的后序编号。例如在图 #$ ! 中,结点 &、( 和 ) 的后序编号分别为 !、" 和 &。 结点的后序编号具有这样的特点:设结点 # 的真子孙个数为 -.*0(/),那么在以 # 为根的子 树中的所有结点的后序编号恰好落在 ()*+),-.,(/)1 -.*0(/)与 ()*+),-.,(/)之间。因此为了检 验结点 / 是否为结点 0 的子孙,只要判断它们的后序编号是否满足 ()*+),-.,(2)1 -.*0(2)#()*+),-.,(3)#()*+),-.,(2) 前序编号也具有类似的性质。 在讨论表时,给表的每一位置赋予一个元素值。同样,对于树也用树的结点来存储元素,即 对树中每一结点赋予一个标号,这个标号并不是该结点的名称,而是存储于该结点的一个值。结 点的名称总是不变的,而它的标号是可以改变的。可以作这样的类比: 图 #$ 4% 带标号的表达式树 树:表 5 标号6 元素 5 结点6 位置 例如,算术表达式( 7 8 9)%( 7 8 0)可以用图 #$ 4 中的标号树来表示。其中 #! ,#" ,⋯ ,## 是各结点的名 称,标号记在结点旁边。表示算术表达式的标号树的构 造规则如下: " 每个叶结点的标号是一个运算对象,且称这个运 算对象为该叶结点所代表的表达式。例如,叶结点 #’ 所 代表的表达式为 1。 # 每一个内部结点 # 的标号是一个运算符。如果结点 # 的标号是一个 " 元运算符 $,且 # 的左儿子代表的表达式为 :!,其右儿子代表的表达式为 :",则 # 所代表的表达式为( :!)$ !"# 数据结构与算法 (!")。其中的括号在不必要时可省略。 例如,结点 !" 的标号是 # ,其左、右儿子所代表的表达式分别为 $ 和 %,因而 !" 代表($)# (%),或简记为 $ # %。结点 !& 的标号是!,其左、右儿子 !" 和 !’ 分别代表 $ # % 和 $ # (,故结点 !& 代表的表达式为($ # %)!($ # ()。 对一棵树进行遍历时,常常不是将结点的名字列表,而是将结点的标号列表。一棵表达式树 的前序标号表就是所谓的前缀形式的表达式,其中的每个运算符都写在其左、右运算对象之前。 例如(!&)!(!")的前缀表达式为 !)&)",其中 ! 是二元运算符,)& 和 )" 分别是 !& 和 !" 的前缀 表达式。前缀表达式中的括号可以省略,因为在字符串 )&)" 的各个前缀表达式中,)& 是最短的 前缀表达式。当从前向后扫描前缀表达式 !)&)" 时,可以惟一地确定 )&。 例如,图 *+ , 中树的前缀标号表为! # $% # $(。在字符串 # $% # $( 的前缀中,# $% 是最短的 前缀表达式,因此它就是 !" 所代表的前缀表达式。 类似地,一棵表达式树的后序标号表就是所谓的后缀形式(波兰形式)的表达式。同样,后 缀表达式也不必使用括号。 一棵表达式树的中序标号表是中缀形式的(也就是通常形式的)表达式,但未加括号。在中 缀形式的算术表达式中,有些括号是不能省略的。 *+ ’- 树的表示法 下面讨论表示树的几种基本方法。 !" #" $% 父结点数组表示法 图 *+ .- 树及其父结点数组表示法 设 " 是一棵树,其中结点的名称分别为 &,",⋯ ,!。表示 " 的一种最简单的方法是用一个 一维数组存储每个结点的父结点。由于 树中每个结点的父结点是惟一的,所以上 述的父亲数组表示法可以惟一地表示任 何一棵树。在这种表示法下,寻找一个结 点的父结点只需要 #(&)时间。图 *+ .% 中的数组是表示图 *+ .$ 中的树的父结点 数组。 在树的父结点数组表示法中,对于涉 及查询儿子结点和兄弟结点信息的运算, 可能要遍历整个数组。为了减少查询时 间,可以规定在树的父结点数组中儿子结 点的下标值大于父结点的下标值,且兄弟 结点的下标值是从左到右递增的。 !"#第 * 章- 树 图 !" !# 树的儿子链表 !" #" $% 儿子链表表示法 树的另一种常用的表示方法是对树的每 个结点建立一个儿子结点表。由于各结点的 儿子结点数目多少不一,所以常用链表来实 现儿子结点表。 表示图 !" $% 中树的链表结构如图 !" ! 所示。树中各结点的儿子表的表头存放于数 组 &’%(’) 中,数组下标作为各结点的名称,分 别为 *,+,⋯ ,,。每一个表头指针指向一个 以树中结点为元素的链表。&’%(’)[-]所指的 表由结点 ! 的所有儿子构成。 !" #" #% 左儿子右兄弟表示法 树的左儿子右兄弟表示法又称为二叉树表示法或二叉链表表示法,即以二叉链表作为树的 存储结构。链表中结点的两个链域分别指向该结点的最左儿子和右邻兄弟。图 !" .% 中树的左 儿子右兄弟表示法如图 !" ./ 所示。 图 !" .# 树的左儿子右兄弟表示法 !"# 数据结构与算法 !" #$ 二$ 叉$ 树 二叉树是一类非常重要的特殊的树形结构,它可以递归地定义如下: 二叉树 ! 是有限个结点的集合,它或者是空集,或者由一个根结点 " 以及分别称为左子树和 右子树的 % 棵互不相交的二叉树 "(&)和 "(%)组成。若用 #、#& 和 #% 分别表示 !,"(&)和 "(%) 的结点数,则有 # $ & ’ #& % #% 。子树 "(&)和 "(%)有时分别称为 ! 的第一和第二子树。 二叉树的根可以有空的左子树或空的右子树,或者左、右子树均为空。因此,二叉树有 ( 种 基本形态,如图 !" ) 所示。 图 !" )$ 二叉树的 ( 种基本形态(其中&表示空) 在二叉树中,每个结点至多有 % 个儿子,并且有左、右之分。因此任一结点的儿子不外 # 种 情况:没有儿子,只有一个左儿子,只有一个右儿子,有一个左儿子并且有一个右儿子。显然二叉 树与度数不超过 % 的树不同,与度数不超过 % 的有序树也不同。在有序树中,虽然一个结点的儿 子之间是有左右次序的,但若该结点只有一个儿子时,就无须区分其左右次序。而在二叉树中, 即使是一个儿子也有左右之分。例如图 !" &*+ 和图 !" &*, 是两棵不同的二叉树。虽然它们与图 !" && 中的普通树(作为无序树或有序树)很相似,但它们却不能等同于这棵普通的树。若将这 - 棵树均看作是有序树,则它们是相同的。 图 !" &*$ 两棵不同的二叉树 图 !" &&$ 一棵普通树 由此可见,尽管二叉树与树有许多相似之处,但二叉树不是树的特殊情形。 二叉树具有以下的重要性质: ! 高度为 &$* 的二叉树至少有 & ’ & 个结点。 " 高度不超过 & 的二叉树至多有 %& ’ & . & 个结点。 !"!第 ! 章$ 树 ! 含有 !$! 个结点的二叉树的高度至多为 ! " !。 " 含有 !$! 个结点的二叉树的高度至少为 #$%! ,因此其高度为 !(#$%!)。 具有 ! 个结点的不同形态的二叉数的数目在一些涉及二叉树的平均情况复杂性分析中是很 有用的。设 "! 是含有 ! 个结点的不同二叉树的数目。由于二叉树是递归定义的,所以很自然地 得到关于 "! 的递归方程 "! # $ $ ! ’ !%! & # & "& "!%&% { ! $ $ ! # & ! $ ! 即一棵具有 ! 个结点的二叉树可以看成是由一个根结点、一棵具有 & 个结点的左子树和一 棵具有 ! " & " ! 个结点的右子树所组成。 上述递归方程的解是 "! # ! ! ’ ! ’!( )! ,即所谓的 ()*)#)+ 数。 当 ! , - 时,"- , .。由此可知,有 . 棵含有 - 个结点的不同的二叉树,如图 /0 !’ 所示。 图 /0 !’1 含有 - 个结点的不同二叉树 满二叉树和近似满二叉树是二叉树的两种特殊情形。 一棵高度为 ( 且有 ’( 2 ! " ! 个结点的二叉树称为满二叉树。 若一棵二叉树至多只有最下面的 ’ 层上结点的度数可以小于 ’,并且最下面一层上的结点 都集中在该层最左边,则称这种二叉树为近似满二叉树(有时也称为完全二叉树)。 图 /0 !-1 特殊形态的二叉树 例如图 /0 !-) 是一棵高度为 - 的满二叉树。满二叉树的特点是每一层上的结点数都达到最 大值,即对给定的高度,它是具有最多结点数的二叉树。满二叉树中不存在度数为 ! 的结点。每 个分枝结点均有 ’ 棵高度相同的子树,且叶结点都在最下面一层上。图 /0 !-3 是一棵近似满二 叉树。显然满二叉树是近似满二叉树,但近似满二叉树不一定是满二叉树。在满二叉树的最下 !"# 数据结构与算法 层上,从最右结点开始连续往左删去若干个结点后得到的二叉树是一棵近似满二叉树。因此,在 近似满二叉树中,若某个结点没有左儿子,则它一定没有右儿子,即该结点是一个叶结点。图 !" #$% 中,结点 & 没有左儿子而有右儿子 ’,故它不是一棵近似满二叉树。 !" () *+, 二 叉 树 二叉树的最重要的作用之一是用以实现各种各样的抽象数据类型。与表的情形相同, 定义在二叉树上的运算也是多种多样的。这里只考虑作为抽象数据类型的二叉树的几种 典型运算。 ! -./0123/.4():创建一棵空二叉树。 " -./01256742(,):判断一棵二叉树 ! 是否为空。 # 8994(,):返回二叉树 , 的根结点标号。 $ :0;<,1<<(=,,,’,8):以 " 为根结点元素,分别以 # 和 $ 为左、右子树构建一棵新的二叉 树 !。 % -1<0;,1<<(,,’,8):函数 :0;<,1<< 的逆运算,将二叉树 ! 拆分为根结点元素,左子树 # 和右子树 $ 等 $ 部分。 & >19B4?1@<1(A.B.4,4):后序遍历二叉树。 ) >19B4?C4(,):二叉树后序列表。 +,. +(..; #<1.-.2 6#(;5# "%&4(<#(..{ ! ! ! "#$%&’ (,,#;! !!树根 !! }+>(..; 其中,(,,# 是指向树根的指针。 图 ?= @A! 用指针实现二叉树 +%&4((.. +%&4((.. > "34$$,5(6%7.,2 !>); ! > $%(,,# "8; ! (.#;(& >; } 用指针实现的图 ?= @?4 中二叉树如图 ?= @A 所示。 下面来讨论抽象数据类型二叉树的基本 运算。 函数 +%&4(<931#<(>)简单地检测 ! 的根结 点指针 (,,# 是否为空指针。 函数 C,,#(>)返回 > 的根结点标号。 !"# 数据结构与算法 !"# $!"%&’()*#’($!"%&’+&,, +) { - - &,#.&" + $%&//# ""0; } +&,,1#,) 2//#($!"%&’+&,, +) { - !3($!"%&’()*#’(+))(&&/&(#+&,, !4 ,)*#’#); - &,#.&" + $%&//# $%,5,),"#; } 函数 6%7,+&,,(8,+,9,2)以 ! 为根结点元素,分别以 " 和 # 为左、右子树构建一棵新的二叉 树 $。 :/!; 6%7,+&,,(+&,,1#,) 8,$!"%&’+&,, +,$!"%&’+&,, 9,$!"%&’+&,, 2) {- !!构建新二叉树 !! - + $%&//# "<,=$?# "2 $%&//#; - 9 $%&//# "2 $%&//# "0; } 函数 $&,%7+&,,(+,9,2)执行函数 6%7,+&,,(8,+,9,2)的逆运算,将二叉树 $ 拆分为根结点 元素 ,5,),"#,左子树 " 和右子树 # 等 @ 部分。 +&,,1#,) $&,%7+&,,($!"%&’+&,, +,$!"%&’+&,, 9,$!"%&’+&,, 2) {- !!二叉树拆分 !! - +&,,1#,) 8; - !3(!+ $%&//#)(&&/&(#+&,, !4 ,)*#’#);- !!空树 !! - 8 "+ $%&//# $%,5,),"#; - 9 $%&//# "+ $%&//# $%5,3#; - 2 $%&//# "+ $%&//# $%&!>?#; - + $%&//# "0; - &,#.&" 8; } 下面的 @ 个函数分别实现对二叉树的前序遍历、中序遍历和后序遍历。这 @ 个函数都以二 !"#第 A 章- 树 叉树结点 !,结点访问函数 !"#"$ 作为参数,对以结点 ! 为根的子树递归地进行相应的遍历操作。 !%"& ’()*(&)(( !%"&(!!"#"$)(+$,"-. /),+$,"-. $) {0 !!前序遍历 !! 0 "1($){ 0 0 (!!"#"$)($); 0 0 ’()*(&)((!"#"$,$ $%,)1$); 0 0 ’()*(&)((!"#"$,$ $%("23$); 0 0 } } !%"& 4-*(&)(( !%"&(!!"#"$)(+$,"-. /),+$,"-. $) {0 !!中序遍历 !! 0 "1($){ 0 0 4-*(&)((!"#"$,$ $%,)1$); 0 0 (!!"#"$)($); 0 0 4-*(&)((!"#"$,$ $%("23$); 0 0 } } !%"& ’%#$*(&)(( !%"&(!!"#"$)(+$,"-. /),+$,"-. $) {0 5 !后序遍历 !5 0 "1($){ 0 0 ’%#$*(&)((!"#"$,$ $%,)1$); 0 0 ’%#$*(&)((!"#"$,$ $%("23$); 0 0 (!!"#"$)($); 0 0 } } 上述 6 种遍历都是递归定义的。事实上,可以用栈模拟递归,用非递归方式实现上述 6 种遍 例。例如,非递归前序遍历算法可描述如下: !%"& ’()*(&)(( !%"&(!!"#"$)(+$,"-. /),+$,"-. $) {0 !!非递归前序遍历 !! 0 7$89. # "7$89.4-"$(); 0 ’/#3($,#); 0 :3",)(!7$89.;<=$>(#)){ 0 0 (!!"#"$)($ "’%=(#)); !"# 数据结构与算法 ! ! "#($ $%%"&’$)()*’($ $%%"&’$,*); ! ! "#($ $%+,#$)()*’($ $%+,#$,*); ! ! } } 对树中结点按层序遍历是指先访问树根,然后从左到右地依次访问所有深度为 - 的结点,再 从左到右地访问所有深度为 . 的结点,等等。用一个队列 ! 存储待访问结点,容易实现对二叉树 的层序遍历。 /0"1 2,/,+3%1,%( /0"1(!/"*"$)(4$+"56 )),4$+"56 $) {! !!层序遍历 !! ! 7),), 8 "7),),95"$(); ! :5$,%7),),($,8); ! ;’"+,(!7),),:<=$>(8)){ ! ! (!/"*"$)($ "?,+,$,7),),(8)); ! ! "#($ $%+,#$):5$,%7),),($ $%+,#$,8); ! ! "#($ $%%"&’$):5$,%7),),($ $%%"&’$,8); ! ! } } 函数 (%,3)$、953)$、(0*$3)$ 和 2,/,+3)$ 通过对二叉树的根结点调用结点元素输出函数 0)$@ 501, 来实现对整个二叉树结点的前序列表、中序列表、后序列表和层序列表。 /0"1 0)$501,(4$+"56 $) { ! A%,,9$,A%,, A) { ! (%,3%1,%(0)$501,,A $%%00$); } /0"1 953)$(C"5D%>A%,, A) { ! 953%1,%(0)$501,,A $%%00$); } !"#第 E 章! 树 !"#$ %"&’()’(*#+,-./-00 /) { 1 %"&’(-$0-(")’+"$0,/ $%-""’); } !"#$ 20!03()’(*#+,-./-00 /) { 1 20!03(-$0-(")’+"$0,/ $%-""’); } 函数 40#56’ 返回二叉树的高度。 #+’ 40#56’(7’3#+8 ’) {1 !!二叉树的高度 !! 1 #+’ 63,6-; 1 #9(!’)-0’)-+ $:; 1 63 "40#56’(’ $%309’);1 1 !!左子树的高度 !! 1 6- "40#56’(’ $%-#56’);1 !!右子树的高度 !! 1 #9(63 %6-)-0’)-+ &&63; 1 03&0 -0’)-+ &&6-; } ;< ;1 线索二叉树 用指针实现二叉树时,每个结点只有指向其左、右儿子结点的指针,所以从任一结点出发只 能直接找到该结点的左、右儿子。在一般情况下无法直接找到该结点在某种遍历序下的前驱和 后继结点。如果在每个结点中增加指向其前驱和后继结点的指针,将降低存储效率。用指针实 现二叉树时,在 ! 个结点二叉树中含有 ! = : 个空指针。可以利用这些空指针存放指向结点在某 种遍历次序下的前驱和后继结点的指针。这种附加的指针称为“线索”。加上了线索的二叉树 称为线索二叉树。 为了区分一个结点的指针是指向其儿子结点的指针,还是指向其前驱或后继结点的线索,可 在每个结点中增加 > 个线索标志。这样,线索二叉树结点类型定义为: ’.?0$09 &’-)@’ 7’+"$0 !’7’3#+8; &’-)@’ ’7’+"$0 { /-00A’0B 030B0+’; 7’3#+8 309’;1 1 1 1 !!左子树 !! !"# 数据结构与算法 !"#$%& ’$()";* * * !!右子树 !! $%" #+,"-)’+./,* * !!左线索标志 !! 图 01 23* 线索二叉树 ! "#$%&’%"()*;! !!右线索标志 !! }-)’+./+/45/+; 其中,#+,"-)’+./ 为左线索标志,’$()"-)’+./ 为右 线索标志,它们的含义是:当 #+,"-)’+./ 的值为 6 时,#+," 是指向左儿子结点的指针;当 #+,"-)’+./ 的值为 2 时,#+," 是指向前驱结点的左线索。类 似地,当 ’$()"-)’+./ 的值为 6 时,’$()" 是指向右 儿子结点的指针;当 ’$()"-)’+./ 的值为 2 时,’$()" 是指向后继结点的右线索。 图 01 23 是一棵中序线索二叉树,其指针表示 如图 01 76 所示。 图 01 76* 线索二叉树的指针表示 图 01 76 中,增加了一个头结点,其 #+," 指针指向二叉树的根结点,其 ’$()" 指针指向中序遍历 的最后一个结点。另外,二叉树中依中序列表的第一个结点的 #+," 指针和最后一个结点的 ’$()" 指针指向头结点。这就像为二叉树建立了一个双向线索链表,既可从第一个结点起,沿后继结点 进行遍历,也可从最后一个结点起沿前驱结点进行遍历。 如何在线索二叉树中找结点的前驱和后继结点?以图 01 23 的中序线索二叉树为例,树中所 有叶结点的右链是线索,因此叶结点的 ’$()" 指针指向该结点的后继结点,如图 01 23 中结点 ! 的 后继结点为结点 "。当一个内部结点右线索标志为 6 时,其 ’$()" 指针指向其右儿子结点,因此 无法由 ’$()" 指针得到其后继结点。然而,由中序遍历的定义可知,该结点的后继结点应是遍历 其右子树时访问的第一个结点,即右子树中最左下的结点。例如在找结点 " 的后继时,首先沿 右指针找到其右子树的根结点 #,然后沿其 #+," 指针往下直至找到其左线索标志为 2 的结点,即 为其后继结点(在图中是结点 $)。类似地,在中序线索树中找结点的前驱结点的规律是:若该结 !"!第 0 章* 树 点的左线索标志为 !,则 "#$% 为线索,直接指向其前驱结点,否则遍历左子树时最后访问的那个结 点,即左子树中最右下的结点为其前驱结点。由此可知,若线索二叉树的高度为 !,则在最坏情 况下,可在 "(!)时间内找到一个结点的前驱或后继结点。 对一棵非线索二叉树,以某种次序遍历使其变为一棵线索二叉树的过程称为二叉树的线 索化。由于线索化的实质是将二叉树中的空指针改为指向其前驱结点或后继结点的线索,而 一个结点的前驱或后继结点的信息只有在遍历时才能得到,因此线索化的过程即为在对二叉 树的遍历过程中修改空指针的过程。为了记下遍历过程中访问结点的先后次序,可附设一个 指针 &’# 始终指向刚访问过的结点。当指针 & 指向当前访问的结点时,&’# 指向它的前驱结 点。由此也可推知 &’# 所指结点的后继结点为 & 所指的当前结点。这样就可在遍历过程中将 二叉树线索化。 对于找前驱和后继结点两种运算而言,线索二叉树优于非线索二叉树。但线索二叉树也有 其缺点。在进行结点插入和删除运算时,线索二叉树比非线索二叉树的时间开销大。原因在于 在线索二叉树中进行结点插入和删除时,除了修改相应指针外,还要修改相应的线索。 () *+ 应+ + 用 各种资源传输网络的功能是将始发地的资源通过网络传输到一个或多个目的地。例如,通 过石油或者天然气输送管网可以将从油田开采的石油和天然气传送给消费者。同样,通过高压 传输网络可以将发电厂生产的电力传送给用电消费者。为了使问题更具一般性,用术语信号统 称网络中传输的资源(石油、天然气、电力等)。各种资源传输网络统称为信号传输网络。信号 经信号传输网络传输时,需要消耗一定的能量,并导致传输能量的衰减(油压、气压、电压等)。 当传输能量衰减量(压降)达到某个阈值时,将导致传输故障。为了保证传输畅通,必须在传输 网络的适当位置放置信号增强装置,确保传输能量的衰减量不超过其衰减量容许值。下面讨论 对于一个给定的信号传输网络如何放置最少的信号增强装置来保证网络传输的畅通。 图 () ,!+ 树状信号传输网络 为了简化问题,假定给定的信号传输网络是以信号始 发地为根的一棵树 #。在树 # 的每一个结点处(除根结点 外)可以放置一个信号增强装置。树 # 的结点也代表传输 网络的消费结点。信号经过树 # 的结点传输到其儿子结 点。图 () ,! 是一个树状信号传输网络。 树的每一边上的数字是流经该边的信号所发生的信号 衰减量。信号衰减量是可加的,例如,图 () ,! 中,从结点 $ 到结点 % 的信号衰减是 -,从结点 & 到结点 ’ 的信号衰减是 .。为了便于讨论,将信号传输网络的信号衰减量容许值记 为 %/"#’#01#,将结点 ( 与其父结点间的信号衰减量记为 )( ()。例如,在图 () ,! 中,)( *)2 ,, )($)2 3,)(+)2 .。由于只能在树 # 的结点处放置信号增强装置,因此信号传输网络中每一结 点 ( 均有 )(()#%/"#’#01#,否则问题无解。 对于网络中任何结点 (,用 ,(()表示从结点 ( 到以 ( 为根的子树中叶结点的最大信号衰减 !"# 数据结构与算法 量。当结点 ! 是一个叶结点时,"(!)! "。在图 #$ %& 中,使 "(!)! " 的结点是 !({ #,$,%,&,’ }。其余结点的 "(!)值可以按下式计算 "(!)! ’(){"(()* )(()+ ( 是 ! 的儿子结点} 由此可知 "(*)! %。从上面计算 "(!)的公式可以看出仅当已计算出 ! 的儿子结点的 " 值, 才能计算出 "(!)的值。因此,可以采用树的后序遍历方式计算树 + 的每个结点的 " 值。 假设在以后序遍历方法计算树 + 的每个结点的 " 值时,遇到一个结点 !,它的一个儿子结点 ( 使 "(()* )(() , -./010230。如果结点 ( 处未放置一个信号增强装置,那么即使在结点 ! 处放置 了信号增强装置,也无法使其最大信号衰减量不超过容许值 -./010230。例如,在图 #$ %& 中,假设 -./010230 ! 4。在计算 "(,)时发现 "(*)* )(*)! 5 , -./010230。应当在结点 * 处放置一个信号 增强装置。此时,"(,)! %。 上述计算树 + 的每个结点的 " 值并放置信号增强装置的算法可描述如下: 6(7)""; 8.1( 7 的每一儿子结点 9 ){ : : 78( 6(9)&;(9))%-./010230)在结点 9 放置一个信号增强装置; : : 0/<0 6(7)"’()(6(7),6( 9)&;( 9)); : : } 图 #$ %%: 最优信号增强装置布局方案 用这个算法对图 #$ %& 中信号传输网络计算的结果如 图 #$ %% 所示,其中阴影结点为放置了信号增强装置的结 点,结点中的数字为该结点的 " 值。 设信号传输网络中结点个数为 -。对 - 用数学归纳法 可以证明,上述算法所产生的信号增强装置布局是一个最 优布局方案,即该方案用的信号增强装置最少。 事实上,当 - ! &,上述论断显然成立。假定上述论断 当 -#. 时成立,设 + 是一棵有 - * & 个结点的树。设用 上述算法找到的放置信号增强装置的结点集为 /,而 0 是 一个最优放置方案的放置了信号增强装置的结点集。要证明的是+ / + ! + 0+ 。 当+ / + ! " 时,显然有+ / + ! + 0+ 。当+ / + , " 时,设 ’ 是上述算法放置信号增强装置的第一个 结点。以结点 ’ 为根的子树记为 +’。由于 "(’)* )(’), -./01(230,0 必包含 +’ 中的至少一个结 点 1。如果 0 包含 +’ 中多于一个结点,则 0 就不是最优的。因为结点集 0 = 0)+’ *{’}同样 满足信号衰减量容许值约束,但结点个数更少。由此可见,0 中恰有一个 +’ 中结点 1。设 0& ! 0 ={1},+& 是树 + 中删去子树 +’ 但保留结点 ’ 的树,易知,0& 是 +& 的信号增强装置最优布局 方案。另一方面,/& ! / ={’}是树 +& 的一个满足信号衰减量容许值约束的信号增强装置布局 方案,而且该方案是上述算法应用于树 +& 产生的。树 +& 的结点数少于 . * &,由归纳假设知 + /& + ! + 0& + 。从而+ / + ! + /& + * & ! + 0& + * & ! + 0 + 。由数学归纳法即知,/ 是信号传输网络 + 的一个最优信号增强装置布局方案。 当信号传输网络是一棵二叉树时,可将二叉树结点定义为: !"#第 # 章: 树 !"#$%$& ’!()*! +,,’!$({ - - ./! 0,%; - - ./! +,,’!;- !!信号增强装置标志 !! }1,,’!$(; 2,.% ’3,4+,,’!(1,,’!$( 5) { - - #(./!&(#6 - %- 6 %- 6 % ’/#,57 +,,’!,57 0,57 %); } 用二叉树的后序遍历方式计算树 ! 时,在结点 " 处计算 # 值以及确定是否在结点 " 处放置 信号增强装置的函数 89:*$(5)如下: 2,.% 89:*$(+!9./; 5) { - ./! %$<; - +!9./; " "5 $%9$&!; - 5 $%$9$=$/!7 0 ">;- !!初始化结点 5 处的信号衰减量 !! - .&("){- !!从左子树计算 !! - - - - - %$< "" $%$9$=$/!7 0 &" $%$9$=$/!7 %; - - - - - .&(%$< %!,9) - - - - - - - {" $%$9$=$/!7 +,,’! "?; - - - - - - - 5 $%$9$=$/!7 0 "" $%$9$=$/!7 %;} - - - - - $9’$ 5 $%$9$=$/!7 0 "%$<; - - - - - } - " "5 $%(.<3!; - .&("){- !!从右子树计算 !! - - - - - %$< "" $%$9$=$/!7 0 &" $%$9$=$/!7 %; - - - - - .&(%$< %!,9) - - - - - - - {" $%$9$=$/!7 +,,’! "?; - - - - - - - %$< "" $%$9$=$/!7 %;} - - - - - .&(5 $%$9$=$/!7 0 (%$<) - - - - - - - 5 $%$9$=$/!7 0 "%$<; - - - - - } } 以 89:*$ 为访问函数,后序遍历树 ! 可以找到树 ! 的最优信号增强装置布局方案。由于函 !!" 数据结构与算法 数 !"#$% 耗时 !(&),后序遍历一棵有 " 个结点的树耗时 !("),因此,上述算法在 !(")时间内找 到树 # 的最优信号增强装置布局方案。下面的主函数 ’#() 是对图 *+ ,& 的树,计算最优信号增 强装置布局方案的例子。 -.(/ ’#()() { 0 1..23%4 #,5; 0 1()#4674%% 7,8,9,:,;,<; 0 7 "1()#46=)(3(); 0 8 "1()#46=)(3(); 0 9 "1()#46=)(3(); 0 : "1()#46=)(3(); 0 ; "1()#46=)(3(); 0 < "1()#46=)(3(); 0 #+ / ",;#+ > "?;#+ 5..23 "?; 0 5+ / "&;5+ > "?;5+ 5..23 "?; 0 @#A%74%%(#,8,;,;); 0 @#A%74%%(5,9,8,;); 0 @#A%74%%(#,8,;,;); 0 @#A%74%%(#,:,8,;); 0 5+ / "B; 0 @#A%74%%(5,8,9,:); 0 @#A%74%%(#,9,;,;); 0 5+ / "&; 0 @#A%74%%(5,:,;,;); 0 @#A%74%%(#,<,9,:); 0 @#A%74%%(#,:,;,;); 0 @#A%74%%(5,7,<,:); 0 5+ / "?; 0 @#A%74%%(5,9,7,8); 0 !.23C4/%4(!"#$%,9 $%4..3); 0 !.23CD3(9); } 计算结果如图 *+ ,, 所示。 当信号传输网络是一棵多叉树 # 时,可以用本章介绍的树的左儿子右兄弟表示法将 # 表示 为一棵二叉树,前面讨论的结论和算法仍然有效。 !"#第 * 章0 树 本 章 小 结 本章主要讲授常用的非线性层次结构树以及作为抽象数据类型的树的一般操作和一些常用 的表示树的数据结构。在给出树的准确定义后,讨论了树的前序遍历、中序遍历和后序遍历方 法。对于一般情况下的树结构,介绍了实践中常用的树的父结点数组表示法、树的儿子链表表示 法和树的左儿子右兄弟表示法。二叉树是一类非常重要的特殊的树形结构,也是本章内容的重 点。二叉树和 !"# 二叉树的概念是本章的核心概念,在后续各章中也反复用到。二叉树的顺序 存储结构、二叉树的结点度表示法和用指针实现二叉树的方法是实现二叉树的 $ 种常见方法。 本章着重讨论了用指针实现二叉树的方法,在此方法的基础上还引申出线索二叉树结构。最后 以信号传输网络中最优信号增强装置布局问题为例讨论了树结构在实际问题中的应用方法。 习% % 题 !" #$ 如果习题 &’ ( 表中的第 ! 行与第 " 列所代表的两种情况能够同时发生,试在 ! 行 " 列的空格中填入*, 否则填入 ) 。 习题 !" # 表 % % % % % % 列 行% % % % % % % *+,-+.,+(/) 0 *+,-+.,+(1) 2/-+.,+(/) 0 2/-+.,+(1) *-34-+.,+(/) 0 *-34-+.,+(1) / 在 1 左边 / 在 1 右边 / 是 1 的真祖先 / 是 1 的真子孙 !" %$ 设 $ 个数组 5+,-+.,+、6/-+.,+ 和 5-34-+.,+ 分别给出了树中每一个结点的前序、中序和后序编号,试写一 个算法,对任一对结点 ! 和 ",判断 ! 是否为 " 的祖先,并说明算法的正确性。 !" &$ 已知一棵度为 # 的树中有 $(()个度为 ( 的结点,$(7)个度为 7 的结点⋯ ⋯ $(#)个度为 # 的结点,问 该树中有多少个叶结点? !" ’$ 一个高度为 % 的满 & 叉树有如下性质:第 % 层上的结点都是叶结点,其余各层上每个结点都有 & 棵非 空子树。如果按层次顺序从 ( 开始对树中所有结点进行编号,问: (()各层的结点数目是多少? (7)编号为 $ 的结点的父结点(若存在)的编号是多少? ($)编号为 $ 的结点的第 ! 个儿子结点(若存在)的编号是多少? (8)编号为 $ 的结点有右兄弟的条件是什么?其右兄弟的编号是多少? !" ($ 试分别找出满足下面条件的所有二叉树: (()前序列表与中序列表相同。 (7)中序列表与后序列表相同。 ($)前序列表与后序列表相同。 !" )$ 分别写出以非递归方式按前序、中序和后序遍历二叉树的算法。 !"# 数据结构与算法 !" !# 设计一个将表达式变换为表达式树的算法和一个将表达式树变换为后缀表达式的算法。 !" $# 设计一个算法,对于给定的二叉树中两结点返回它们的最近公共祖先。 !" %# 试写一个搜索算法,计算出一给定二叉树中任意 ! 个结点之间的最短路径。 图 "# !$% 叶收缩运算 !" &’# 图 "# !$ 所示的运算称为二叉树的叶收缩运算。 设 ! 和 " 为 ! 棵二叉树。若通过对二叉树 " 执行 # 次叶收缩运算后 得到一棵与 ! 同构的二叉树,则称二叉树 ! 为二叉树 " 的一个前缀。试 设计一个算法来判断一棵二叉树是否为另一棵二叉树的前缀。 !" &&# 图 "# !& 所示的运算称为二叉树的根收缩运算。 设 ! 和 " 为两棵二叉树。若对二叉树 " 执行 # 次根收缩运算后得到 一棵与二叉树 ! 同构的二叉树,则称二叉树 ! 为二叉树 " 的一个后缀。试设计一个算法来判断一棵二叉树是否 为另一棵二叉树的后缀。 图 "# !&% 根收缩运算 !" &(# 图 "# !’ 所示的运算称为二叉树的结点旋转变换。 给定 ! 棵结点个数相同的二叉树,可以通过一系列的结点旋转变换,将其中的一棵二叉树变换为另一棵二 叉树。试设计一个完成上述变换的算法,并以此证明这个结论的正确性。 图 "# !’% 结点旋转变换 !" &)# 若对二叉树 " 施行若干次叶收缩运算和根收缩运算能将其变换为一棵与二叉树 ! 同构的二叉树,则 称二叉树 ! 是二叉树 " 的一个棵子树。试设计一个算法来判断一棵二叉树是否为另一棵二叉树的子树。 !" &*# 有时需要测试 ! 个数据结构的等价性,即 ! 个等价的结构在相应的位置具有相同结点数和分枝数, 且相应的结点具有相同的标号(值)。试设计一个递归函数 ()*+, 用于测试 ! 棵二叉树是否等价。 !" &+# 试设计复制一棵二叉树的算法。 !" &,# 试设计下面 ! 个算法,建立所要求的二叉树,且使二叉树中结点的标号待定。 (-).*/,01/234(2),建立一棵有 $ 个结点且高度最小的二叉树; (!).*/,05678,949(2),建立一棵有 $ 个结点的近似满二叉树。 !" &!# 设计一个在中序线索二叉树中找一个结点的后继结点的算法。 !" &$# 设计一个在中序线索二叉树中遍历二叉树的算法,并说明该算法所需的计算时间为 %($)。 !" &%# 设计在中序线索二叉树中插入一个结点和删除一个结点的算法。 !" (’# 用二叉树的结点度表示法,分别设计找一个结点的左儿子结点的算法和找一个结点的父结点的 算法。 !" (&# 给出二叉树的一个例子,使得它的结构不能从它的 $ 种遍历次序的任何一种惟一确定。 !"#第 " 章% 树 书书书 第 ! 章" 集" " 合 学习目标 ! 理解集合的概念。 ! 理解以集合为基础的抽象数据类型。 ! 掌握用位向量实现集合的方法。 ! 掌握用链表实现集合的方法。 !" #$ 以集合为基础的抽象数据类型 集合是表示事物的最有效的数学工具之一,生活中也随处可见集合的例子。例如银行中所 有储户账号的集合,图书馆中所有藏书的集合,以及一个程序中所有标识符的集合等,都是常见 的用集合表示一类事物的例子。在数据结构和算法的设计中,集合是许多重要抽象数据类型的 基础。人们已经发明了实现以集合为基础的各种抽象数据类型的许多技巧。本章讨论各种以集 合为基础的抽象数据类型,并研究在计算机上实现这些抽象数据类型的有效方法。 !# $# $" 集合的定义和记号 集合是由元素(成员)组成的一个类。集合的成员可以是一个集合,也可以是一个原子。通 常集合的成员是互不相同的,即同一个元素在一个集合中不能多次出现。 有时需要表示有重复元素的集合,这时允许同一元素在集合中多次出现,这样的集合称为多 重集合。 当集合中的原子具有线性序关系(或称全序关系)“ % ”时,称集合为一有序集(全序集或线 性序集)。“ % ”是集合的一个线性序,有: ! 若 !、" 是集合中任意 & 个原子,则 ! # ",! $ " 和 " # ! 三者必居其一。 ! 若 !、" 和 % 是集合中的原子,且 ! # "," # % 则 ! # %(传递性)。 整数、实数、字符和字符串都有一个自然的线性序,用 % 来表示。在数据结构和算法的设计 中,通常将集合中的元素称作记录,每个记录有多个项(或域)用来表示元素的各种属性。例如 对于图书馆的藏书集合中的每一个元素是一本书,它包括书名、作者、出版地等各种属性。当集 合是有序集时,称集合中元素的序值为(搜索)键。键值也是有序集中元素的一个重要属性,通 过键值可以惟一地确定集合中的一个元素。为了便于叙述,常将一个元素当作一个键来处理,但 要记住键只是元素记录中许多域中的一个域。 表示一个由原子组成的集合,一般是把它的元素列举在一个花括号中。例如{!,"}表示由 ! 和 " 两个元素组成的集合。虽然把集合的元素列举出来像列表一样,但集合不是一个表。集合 中元素的列举顺序是任意的。例如{!,"}和{",!}表示同一集合。 表示集合的另一种方法是给出集合中元素应满足的条件,即把集合表述为:{! " 关于 ! 的说 明}。其中关于 ! 的说明是一个谓词,它确切地指出元素 ! 要成为集合的一个成员应满足的条 件。例如{!" ! 是正整数,且 !"! ###}是集合{!,$,⋯ ,! ###}的另一种表示法。{! " 存在整数 #, 使 ! $ #$ }表示由全体完全平方数组成的集合,它是一个无穷集,无法用列举集合成员的方法来 表示。 成员关系是集合的基本关系。!#% 表示 ! 是集合 % 的成员。这里 ! 可以是一个原子,也可 以是一个集合,但 % 一定是一个集合。当 # 是一个原子时,!## 没有意义。!$% 表示 ! 不是 % 的成员。不含任何元素的集合称为空集合,记作%。!#%对任何 ! 都不成立。 如果集合 % 中每个元素也都是集合 & 的元素,就说集合 % 包含于集合 & 中,或说集合 & 包 含集合 %,记作 %&&。这时,称集合 % 是集合 & 的子集,或集合 & 是集合 % 的扩集。例如{!,$} &{!,$,%}。{!,$}是{!,$,%}的子集,但{!,$,%}不是{!,$}的子集。因为 % 在{!,$,%}中,而不 在{!,$}中。每个集合都包含其自身以及空集合。如果 $ 个集合互相包含,就说这 $ 个集合相 等。如果 %&& 且 %’&,则称 % 是 & 的真子集,& 是 % 的真扩集。 关于集合的最基本的运算是并、交、差运算。设 % 和 & 是 $ 个集合。% 和 & 的并是由 % 的成 员和 & 的成员合在一起得到的集合,记作 %(&。% 与 & 的交是由 % 与 & 所共有的成员组成的集 合,记作 %)&。% 与 & 的差是由属于 % 但不属于 & 的元素组成的集合,记作 %’&。例如,如果 % ${(,),*},& $ {),+},则 %(& ${(,),*,+},%)& ${)},%’& ${(,*}。 !" #" $% 定义在集合上的基本运算 在集合上可以定义各种各样的运算(有时称为操作)。将集合与一些具体的关于集合的运 算结合在一起,就得到一些重要的抽象数据类型。这些抽象数据类型具有专门的名称,并且已研 究出许多高效的实现方法。这里先列举一些最常用的集合运算,其中大写字母表示一个集合,小 写字母表示集合中的一个元素。 ! &’()*+,*(-,.),并集运算:其运算结果为集合 % 与集合 & 的并集。 " &’(/*(’01’2(+,*(-,.),交集运算:其运算结果为集合 % 与集合 & 的交集。 # &’(3+44’0’*2’(-,.),差集运算:其运算结果为集合 % 与集合 & 的差集。 $ &’(-11+5*(-,.),赋值运算:将集合 & 的值赋给集合 %。 % &’(6789:(-,.),判等运算:当集合 % 与集合 & 相等时返回 !,否则返回 #。 & &’(;’<=’0(>,&),成员运算:其中 ! 与集合 , 的元素有相同的类型。当 ! 属于 , 时,返回 !,否则返回 #。 !"#第 ? 章@ 集@ @ 合 ! !"#$%&"’#((,!),插入运算:将元素 ! 插入集合 " 中。! 与集合 " 中元素具有相同类型。当 ! 原来就是集合 " 中的一个元素时,不改变集合 "。 " !"#)"*"#"((,!),删除运算:将元素 ! 从集合 " 中删去。如果 ! 不属于集合 ",则不改变集 合 "。 +, -. 用位向量实现集合 如何有效地实现一个以集合为基础的抽象数据类型,依赖于该集合的大小以及该抽象数据 类型所支持的集合运算。 当所讨论的集合都是全集合{/,-,⋯ ,# }的子集,而且 # 是一个不大的固定整数时,可以用 位向量来实现集合。此时,对于任何一个集合 $&{/,-,⋯ ,# },可以定义它的特征函数为: !$(!)% /. ! # $ 0. ! $ { $ . . 用一个 # 位的向量 & 来存储集合 $ 的特征函数值 &[’]% !$(’),1 % /,-,⋯ ,#,可以惟一地 表示集合 $。位向量 & 的第 ’ 位为 / 当且仅当 ’ 属于集合 $。这种表示法的主要优点是 !"#2"34 5"’、!"#$%&"’# 和 !"#)"*"#" 运算都可以在常数时间内完成,这只要访问相应的位就行了。在这种 集合表示法下,执行并集运算、交集运算和差集运算所需的时间正比于全集合的大小 #。用位向 量实现的集合结构 61#&"# 定义如下: #78"9": &#’;<# 51#&"# !!"#; #78"9": &#’;<# 51#&"#{ 1%# &"#&1=";. . . . . !!集合大小 !! 1%# >’’>7&1="; !!位数组大小 !! ;%&1?%"9 &@A’# !B; !!位数组 !! }61#&"#; 函数 !"#$%1#(&1=")创建一个用位向量实现,可存储集合大小为 &1=" 的空集。 !"# !"#$%1#(1%# &1=") { 1%# 1; !"# ! "3>**A<(&1="A: !!); ! #$&"#&1=" "&1="; !!存储大小为 &"#&1=" 的集合所需的无符号短整数位数 !! ! #$>’’>7&1=" "(&1=" %/C) $$D; ! #$B "3>**A<(&1="!&1="A:(;%&1?%"9 &@A’#)); !!初始化为空集 !! !"# 数据结构与算法 !"#($ "%;$ &&$’(;$ %%)) #$*[$]"%; #(+,#- ); } 函数 )(+.&&$/-(.,0)通过复制表示集合的位向量来实现赋值运算。 *"$1 )(+.&&$/-()(+ .,)(+ 0) { $-+ $; $!(. #$&(+&$’( !"0 #$&(+&$’()2##"#(’)(+& 3#( -"+ +4( &35( &$’(’); !"#($ "%;$ &. #$3##36&$’(;$ %%). #$*[$]"0 #$*[$]; } )(+7(58(#(9,))通过检测元素在表示集合的位向量中相应位,来判定成员属性。 $-+ )(+7(58(#($-+ 9,)(+ )) { $!(9 &% (( 9 $") #$&(+&$’()2##"#(’:-*3;$1 5(58(# #(!(#(-<(’); #(+,#- ) #$*[.##36:-1(9(9)]= 0$+73&>(9); } 其中用到元素在数组中下标定位函数 .##36:-1(9 和位屏蔽函数 0$+73&>。下标定位函数 .##36:-? 1(9 通过将 ! 右移 @ 位获得 ! 在数组中的位置。位屏蔽函数 0$+73&> 则先计算出 ! 除以 AB 的余 数 ",然后将 A 左移 " 位确定 ! 在相应数组单元中的准确位置。 $-+ .##36:-1(9($-+ 9) { #(+,#- 9 $$@; } ,-&$/-(1 &4"#+ 0$+73&>($-+ 9) { #(+,#- A &&(9 = AC); } 函数 )(+2D,3;(.,0)通过检测集合 # 和 $ 的位向量来判定集合 # 和 $ 是否相等。 $-+ )(+2D,3;()(+ .,)(+ 0) !"!第 E 章F 集F F 合 { !"# !,$%#&’( "); !*(+ #$,%#,!-% !". #$,%#,!-%)/$$0$(’1%#, ’$% "0# #2% ,’3% ,!-%’); 4 4 *0$(! "5;! &+ #$’$$’6,!-%;! %%) !*(+ #$&[!]!". #$&[!]){$%#&’( "5;7$%’8;} $%#9$" $%#&’(; } 函数 1%#:"!0"(+,.)通过集合 ! 和 " 的位向量按位或来实现并集运算。 1%# 1%#:"!0"(1%# +,1%# .) { !"# !; 1%# #3; "1%#<"!#(+ #$,%#,!-%); *0$(! "5;! &+ #$’$$’6,!-%;! %%)#3; #$&[!]"+ #$&[!]= . #$&[!]; $%#9$" #3;; } 函数 1%#<"#%$,%>#!0"(+,.)通过集合 ! 和 " 的位向量按位与来实现交集运算。 1%# 1%#<"#%$,%>#!0"(1%# +,1%# .) { !"# !; 1%# #3; "1%#<"!#(+ #$,%#,!-%); *0$(! "5;! &+ #$’$$’6,!-%;! %%)#3; #$&[!]"+ #$&[!]? . #$&[!]; $%#9$" #3;; } 函数 1%#@!**%$%">%(+,.)通过集合 ! 和 " 的位向量按位与和按位异或来实现差集运算。 1%# 1%#@!**%$%">%(1%# +,1%# .) { !"# !; 1%# #3; "1%#<"!#(+ #$,%#,!-%); *0$(! "5;! &+ #$’$$’6,!-%;! %%) 4 #3; #$&[!]"+ #$&[!]A(. #$&[!]? + #$&[!]); $%#9$" #3;; } !"# 数据结构与算法 函数 !"#$%&"’#((,!)通过将集合 ! 的位向量相应位置 ) 来实现元素插入运算。 *+,- !"#$%&"’#(,%# (,!"# !) { ,.(( &/ (( ( $"! #$&"#&,0")1’’+’(’$%*23,- 4"45"’ ’"."’"%6"’); ! #$*[7’’28$%-"((()]9 ":,#;2&<((); } 函数 !"#="3"#"((,!)通过清除集合 ! 的位向量相应位来实现元素删除运算。 *+,- !"#="3"#"(,%# (,!"# !) { ,.(( &/ (( ( $"! #$&"#&,0")1’’+’(’$%*23,- 4"45"’ ’"."’"%6"’); ! #$*[7’’28$%-"((()]> " ? :,#;2&<((); } 当全集合是一个有限集,但不是由连续整数组成的集合时,仍然可以用位向量来表示这个集 合的子集。这时只需要建立全集合的成员与整数{),@,⋯ ,"}之间的一个一一对应即可。一般 地,当 @ 个集合之间具有一一对应关系时,要实现这 @ 个集合中元素的相互转换,可以借助于抽 象数据类型映射(;2AA,%B)来实现。当其中一个集合是整数集时,可以用数组 # 来实现从这个 整数集到另一个集合的映射。此时,数组元素 #[$]表示整数 $ 所对应的另一个集合中的元素。 CD EF 用链表实现集合 用链表来表示集合时,链表中的每个项表示集合的一个成员。表示集合的链表所占用的空 间正比于所表示的集合的大小,而不是正比于全集合的大小。因此,链表可以用于表示一个无穷 全集合的子集。 链表可分为无序链表和有序链表 @ 种类型。当全集为一有序集时,它的任一子集都可以用 有序链表表示。在一个有序链表中,各项所表示的元素 %()),%(@),⋯ ,%(")依序从小到大顺序 排列,即 %())& %(@)& ⋯ & %(")。因此,在一个有序链表中寻找一个元素时,一般不用搜索整个 链表。例如,要求 @ 个大小为 " 的集合的交时,假设这 @ 个集合均为一个有序全集的子集,如果 用无序链表表示这 @ 个集合,就只能一一比较存放在 @ 个链表中的元素,这样做需要比较 ’("@ ) 次。如果用有序链表表示这 @ 个集合,效率就高得多了。例如,当要确定有序链表表示的集合 # 中的元素 % 是否在有序链表表示的集合 ( 中时,只要将元素 % 与 ( 中的元素顺序逐个比较,当遇 到一个与 % 相等的元素,则说明 % 在 @ 个集合的交中,如果没有遇到与 % 相等的元素而遇到一个 比 % 大的元素,则说明 % 不在交集中。另外,如果在 # 中元素 % 的前一个元素是 ),而且已经知道 !"#第 C 章F 集F F 合 ! 中第一个大于或等于 " 的元素是 #,那么就只要让 $ 与 ! 中元素 # 以及 # 以后的元素顺序逐个 比较就行了。这样只要查看 % 和 ! 各一遍,就可以求出 ! 个集合的交,所需要的比较次数为 & (’)。 用有序链表可实现集合 "#$ 如下: $%&#’#( )$*+,$ -.)$ !"#$; $%&#’#( )$*+,$ -.)${ / / -.01 (.*)$;!!指向第一个元素的指针 !! }2.)$; 其中有序链表的结点类型 34’# 为: $%&#’#( )$*+,$ 04’# !-.01; )$*+,$ 04’# { / / 2.)$5$#6 #-#6#0$; / / -.01 0#7$; / / }34’#; 函数 "#$50.$()创建一个空集合。 "#$ "#$50.$() { "#$ " "68--4,().9#4( !"); " #$(.*)$ ":; *#$+*0 "; } 函数 "#$;6&$%(")判定集合 ( 是否为空集合。 .0$ "#$;6&$%("#$ ") { *#$+*0 " #$(.*)$ "":; } 函数 "#$".9#(")返回集合 ( 的大小。 .0$ "#$".9#("#$ ") { !"# 数据结构与算法 !"# $%"; $!"& ’())%"#; ’())%"# "* #$+!),#; $%" "-; ./!$%(’())%"#){ 0 $%" %%; 0 ’())%"# "’())%"# #$"%1#; 0 } )%#()" $%"; } 函数 *%#2,,!3"(2,4)通过复制表示集合的链表来实现赋值运算。在实现集合的赋值运算 时,不能简单地将 ! 的 +!),# 指针指向集合 " 的 +!),# 指针所指的单元。如果这样做,以后对集合 ! 的改变将会引起集合 " 不应有的改变。 56!7 *%#2,,!3"(*%# 2,*%# 4) { $!"& 8,9,’; 9 "4 #$+!),#; 2 #$+!),# "-; !+(9){ 0 2 #$+!),# ":%.:67%(); 0 8 "2 #$+!),#; 0 8 #$%$%;%"# "9 #$%$%;%"#; 0 8 #$"%1# "-; 0 9 "9 #$"%1#; 0 } ./!$%(9){ 0 ’ ":%.:67%(); 0 ’ #$%$%;%"# "9 #$%$%;%"#; 0 ’ #$"%1# "-; 0 9 "9 #$"%1#; 0 8 #$"%1# "’; 0 8 "’; 0 } } 函数 *%#<"#%),%’#!6"(2,4)通过扫描表示集合 ! 和 " 的链表来实现交集运算。 !!"第 = 章0 集0 0 合 !"# !"#$%#"&’"(#)*%(!"# +,!"# ,) { -)%. /,0,1,2,&; !"# #31 "!"#$%)#(); 4 / "+ #$5)&’#; 0 ", #$5)&’#; 1 "6"76*8"(); 2 "1; 79)-"(/ :: 0){ )5(/ #$"-"3"%# ""0 #$"-"3"%#){ 4 & "6"76*8"(); 4 & #$"-"3"%# "/ #$"-"3"%#; 4 & #$%";# "<; 4 1 #$%";# "&; 4 1 "&; 4 / "/ #$%";#; 4 0 "0 #$%";#; 4 } "-’" )5(/ #$"-"3"%# &0 #$"-"3"%#)/ "/ #$%";#; "-’" 0 "0 #$%";#; } )5(1!"2)#31 #$5)&’# "2 #$%";#; 5&""(2); &"#=&% #31; } 实现集合并集运算 !"#>%)*%(+,,)和差集运算 !"#?)55"&"%("(+,,)的算法与上述算法 !"@ #$%#"&’"(#)*%(+,,)很相似。对于并集运算,由于要按递增的顺序将集合 ! 和 " 中的元素添加到 集合 #31 中去,所以当所比较的 A 个元素不相等时,要将较小的元素添加到 #31 中。当算法的主 循环结束时,还要将尚有剩余元素的集合中所剩下的元素都添加到 #31 中去。对于差集运算,仅 当在比较时发现 ! 中元素比 " 中元素小时,才将这个元素添加到 #31 中。 函数 !"#$%’"&#(;,!)通过向表示集合 # 的链表中插入元素 $ 来实现元素插入运算。 B*)8 !"#$%’"&#(C)’#$#"3 ;,!"# !) { -)%. 1,2,&; 1 "! #$5)&’#; !"# 数据结构与算法 ! ""; #$%&’( " (( " #$’&’)’*+ &, ){ - ! ""; - " "" #$*’,+; - } %.(" (( " #$’&’)’*+ "",)/’+0/*; / "1’#123’(); / #$’&’)’*+ ",; / #$*’,+ ""; %.(" ""!)4 #$.%/5+ "/; ’&5’ ! #$*’,+ "/; } 67 8- 应- - 用 古希腊数学家 9/:+25+$’*’5 发现了用集合的方法找到不超过正整数 ! 的所有素数的一个聪 明的算法,即著名的 9/:+25+$’*’5 筛法。该算法开始时将集合 " 设置为包含正整数 ; < ! 的集合。 通过对集合 " 的反复筛选,不断筛去集合中的合数。算法结束时,留在集合 " 中的数就是正整数 ; < ! 中的所有素数。算法每一次筛选都选定一个筛选因子 #,然后从当前集合中删去所有含因 子 # 的合数 ;#,=#,⋯ ,$#。第一次筛选时,算法选取最小的筛选因子 # % ;,筛去集合 " 中所有 偶数。第 ; 个筛选因子是 # % =,这是一个素数。含因子 = 的所有合数是 >,?,@;,@A,⋯ 。由于这 些合数中的偶数 >,@;,@6,⋯ ,已在第一轮筛选中被删除,所以第 ; 轮删去的合数是 ?,@A,;@,⋯ 。 第 = 轮算法选取的筛选因子是当前集合 " 中大于 # 的最小数 A。这个数也是当前集合 " 中大于 # 的最小素数。用筛选因子 # % A 删去的合数是 ;A,=A,AA,⋯ 。上述筛选过程一直进行到集合 " 中的所有合数都删去为止。由于算法删去的数均为合数,所以 ; < ! 中的所有素数都留在集合 " 中。另一方面,算法结束时留在集合 " 中的数均为素数。事实上,如果有一个合数 # 留在集合 " 中,则可以将 # 表示为 # % &$,其中 & 是大于 @ 的素数。由此可见,在上述筛选过程中,素数 & 曾 作为筛选因子,从而将合数 # 从集合 " 中删去。 用集合实现上述 9/:+25+$’*’5 筛法的算法 B/%*+B/%)’5(*)找到并输出不超过正整数 ! 的所 有素数。 C2%3 B/%*+B/%)’5(%*+ *) { - - - %*+ ),D,E20*+; - 4’+ 5 "4’+F*%+(* %@); - - - .2/() ";;) &"*;) %%)4’+F*5’/+(),5);!!初始化集合 5 !! !"#第 6 章- 集- - 合 ! !!考察 " # $%&’(()之间的数 !! ! ! ! )*&(+ "";+!+ &"(;+ %%) ! ! ! !!从 $ 中删去 + 的所有乘子 !! ! ,)(-.’/.+0.&(+,$)) ! ! ! ! )*&(1 "+ %+;1 &"(;1 % "+) ! ! ! ! ! ,)(-.’/.+0.&(1,$))-.’2.3.’.(1,$); ! ! ! !!集合 $ 中剩余的元素均为素数 !! ! ! ! !!输出所有素数 !! ! ! ! 4*5(’ "6; ! ! ! )*&(+ "";+ &"(;+ %%) ! ! ! ! ,)(-.’/.+0.&(+,$)) ! ! ! ! {7&,(’)(’ 8 9 ’,+);,)(4*5(’ %%8 6: "":)7&,(’)(’)(’);} ! ! ! 7&,(’)(’)(’); } 从原理上讲,算法的筛选因子的范围是 " # ! " 6。而实际上只要取 " " ! " !# 即可。事实 上,如果 $ % &’ 是一个合数,且其因子 & 和 ’ 均大于!#,则 ! !$ % &’ ( # # % # 。可见合数 $ 不在 集合 ) 中。因此集合 ) 中的所有合数均有小于或等于!#的素因子 &,从而该数被算法删去。上述 算法实现中,不计算!#,而是等价地用条件 !!"# 来选取筛选因子 !。 本 章 小 结 本章讲授的主题是集合和以集合为基础的抽象数据类型。本章讨论的集合的运算是一些最 常用的集合运算,如并集运算、交集运算、差集运算、赋值运算、判等运算、成员运算、插入运算、删 除运算等。用位向量和用链表是实现集合的 " 个常用的方法。在此基础上,后续章节将继续讨 论若干更复杂的以集合为基础的抽象数据类型及其实现方法。 习! ! 题 !" #$ 设 ; <{6,",=},> <{=,?,@},求下列结果。 (6)-.’A(,*((;,>)。 (")-.’B(’.&$.4’,*((;,>)。 (=)-.’2,)).&.(4.(;,>)。 (?)-.’/.+0.&(6,;)。 (@)-.’B($.&’(C,;)。 (C)-.’2.3.’.(=,;)。 !" %$ 用集合的基本运算写一个函数,打印出一个有穷集中的所有元素。假定已经有打印集合中单个元素 的函数,要求打印元素时必须保存原有的集合。对于这种情形,用哪种数据结构最合适? !"# 数据结构与算法 !" #$ 当全集合可以转换成 ! " ! 之间的整数时,可以用位向量来表示它的任一子集。当全集合是下列集合 时,如何实现这个转换。 (!)整数 #,!,⋯ ,$$。 (%)! " # 的所有整数,!"#。 (&)整数 !,! $ %,! $ ’,⋯ ,! $ %%。 (’)字符 (,),⋯ ,*。 (+)% 个字母组成的字符串,其中每个字母都取自 (,),⋯ ,*。 !" %$ 集合 & 和 ’ 的对称差 ,-./011232452(6,7)定义为:&(’ ( &)’,如图 89 ! 所示。试对集合的位向量和 链表两种表示方法实现对称差运算 ,-./011232452(6,7)。 图 89 !: 集合 & 和 ’ 的对称差 !" &$ 除了集合 & 和 ’ 的对称差运算外,还有许多可以用集合的并和交两种运算组成的集合的复合运算,如 图 89 % 所示。试用集合的并和交两种运算表示图 89 %(、图 89 %)、图 89 %5 这 & 个图形阴影部分表示的集合。对集 合的位向量和链表两种表示方法实现上述集合的复合运算。 图 89 %: 集合的复合运算 !"#第 8 章: 集: : 合 书书书 第 ! 章" 符" 号" 表 学习目标 ! 理解抽象数据类型符号表的概念。 ! 掌握数组实现符号表的方法。 ! 理解开散列和闭散列的概念。 ! 掌握用开散列表实现符号表的方法。 ! 掌握除余法、数乘法、平方取中法、基数转换法和随机数法等散列函数构造 方法。 ! 掌握采用线性重新散列技术的闭散列表实现符号表的方法。 !" #$ 实现符号表的简单方法 在算法设计中用到的集合,往往不做集合的并、交、差运算,而经常需要判定某个元素是否在 给定的集合中,并且要不断地对这个集合进行元素的插入和删除操作。以集合为基础,并支持 %&’(&)*&+、%&’,-.&+’ 和 %&’/&0&’& 这 1 种运算的抽象数据类型有一个专门的名称,叫做符号表。 下面讨论实现抽象数据类型符号表的一些基本方法。 可以用表示集合的链表或位向量来实现符号表。另一种简单方法是用一个定长数组来存储 集合中的元素。这个数组带有一个游标 02.’,指示集合的最后一个元素在数组中的存储位置。 这种表示法当然也可用于表示一般的集合。它的优点是,结构简单,易于操作。它的缺点是,所 表示的集合大小受到数组大小的限制,做删除操作慢。通常集合元素并不占满整个数组,因此, 存储空间也没有得到充分利用。 用数组实现符号表的结构定义如下: ’34&5&6 .’+78’ 2’2* !92*0&; ’34&5&6 .’+78’ 2’2*{ :-’ 2++23.:;&; !"# $%&#; ’(#)#(*!+%#%; },#%-; .%-$()"!#(&!/()创建一个定长数组大小为 &!/( 的空符号表。 .%-$( .%-$()"!#(!"# &!/() { .%-$( . !*%$$01(&!/(02 !.); . "#%33%4&!/( !&!/(; . "#$%&# !5; . "#+%#% !*%$$01(&!/(!&!/(02(’(#)#(*)); 3(#63" .; } 符号表的成员查询函数 .%-$(7(*-(3(8,.)实现如下: !"# .%-$(7(*-(3(’(#)#(* 8,.%-$( .) { !"# !; 203(! !5;! $. "#$%&#;! %%) 9 !2( . "#+%#%[!]!!8 )3(#63" : ; 3(#63" 5; } 符号表的元素插入运算 .%-$()"&(3#(8,.)实现如下: ;0!+ .%-$()"&(3#(’(#)#(* 8,.%-$( .) { !2(!.%-$(7(*-(3(8,.)<< . "#$%&# $. "#%33%4&!/(). "#+%#%[. "#$%&# %%]!8; } 符号表的元素删除运算 .%-$(=($(#((8,.)实现如下: ;0!+ .%-$(=($(#((’(#)#(* 8,.%-$( .) { !"# ! !5; !2(. "#$%&# #5){ !"!第 > 章9 符9 号9 表 ! "#$%&(’ "#()*)[$]!!+ ,, $ $’ "#%)-*)$ %%; ! $.($ $’ "#%)-* ,, ’ "#()*)[$]!!+)’ "#()*)[$]!’ "#()*)[ ""’ "#! %)-*]; ! } } /0 1! 用散列表实现符号表 用数组来实现含有 ! 个元素的符号表,在最坏情况下运算 ’)2%&3&42&5、’)2%&67-&5* 和 ’)8 2%&9&%&*& 运算所需的计算时间为 "(!)。改用链表来实现,结果也不理想。如果用位向量来实 现,虽然每个运算都可以在 "(:)时间内完成,但它只适用于小规模的符号表。 实现符号表的另一个重要技巧是散列(#)-#$7;)技术。用散列来实现符号表可以使符号表 的每个运算所需的平均时间是一个常数值,在最坏情况下每个运算所需的时间正比于集合的 大小。 散列有两种形式。一种是开散列(外部散列),它将符号表元素存放在一个潜无穷的空间 里,因此它能处理任意大小的集合。另一种是闭散列(内部散列),它使用一个固定大小的存储 空间,因此它所能处理的集合大小不能超过其存储空间大小。 !" #" $% 开散列 开散列的基本思想是将集合的元素(可能有无穷多个)划分成有限个类。例如,划分为 <, :,⋯ ,# $ : 这 # 个类。用散列函数 % 将集合中的每个元素 & 映射到 <,:,⋯ ,# $ : 之一,%(&)的 值就是 & 所属的类。函数 %(&)的值称为元素 & 的散列值。上面所说的每一个类称为一个桶,并 且称 & 属于桶 %(&)。 每个桶都用一个表来表示。& 是第 ’ 个表中的元素当且仅当 %(&)( ’ ,即 & 属于第 ’ 个桶。 用散列表来存储集合中的元素时,总希望将集合中的元素均匀地散列到各个桶中,使得当集 合中含有 ! 个元素时,每个桶中平均有 ! ) # 个元素。如果能估计出 !,并选择 # 与 ! 差不多大 小,则每个桶中平均只有 : = 1 个元素。这样,符号表的每个运算所需要的平均时间就是一个与 ! 和 # 无关的小常数。由此可以看出,开散列表是将数组和表结合在一起的一种数据结构,并希 望能利用各自的优点,克服各自的缺点。因此,如何选择“随机”的散列函数,使它能将集合中的 元素均匀地散列到各个桶中是散列技术的一个关键。对此还要作进一步的讨论。这里先来看一 个在字符串集合上定义的散列函数 #)-#:(+)。 $7* #)-#:(>#)5!+) { $7* %&7,$,? !<; %&7 !-*5%&7(+); .@5($ !<;$ $%&7 ;$ %%)? % !+[$]; !"# 数据结构与算法 !"#$!% & ’ ()(; } 其中集合中元素 ! 为字符串。该散列函数将字符串 ! 中的每个字符转换为一个整数,然后将每 个字符所对应的整数相加,用所得和除以 ()( 的余数作为 "(!)的值。显然这个余数是 ),(⋯ , ()) 之一。 用开散列表实现的符号表结构 *+"%,-./0-12" 定义如下: #3+"4"5 .#!$6# 7+"% !*+"%,-./0-12"; #3+"4"5 .#!$6# 7+"%{ 8 8 8 9%# .9:";8 8 8 8 8 8 &!桶数组的大小 !& 8 8 8 9%#(!/5)(;"#<#"= >);&!散列函数 !& 8 8 8 ?9.# !/#; &!桶数组 !& }*+"%; 其中 /# 是桶数组;.9:" 是桶数组的大小;/5(>)是元素 ! 的散列函数。 函数 ,0<%9#(%1$6@"#.,/-./5(>))创建一个空的开散列表,其桶数组的大小为 %1$6@"#.,散列 函数为 /-./5(>)。 *+"%,-./0-12" ,0<%9#(9%# %1$6@"#.,9%#(!/-./5)(;"#<#"= >)) { 8 9%# 9; 8 *+"%,-./0-12" , !=-2276(.9:"75 !,); 8 , "#.9:" !%1$6@"#.; 8 , "#/5 !/-./5; 8 , "#/# !=-2276(, "#.9:"!.9:"75(?9.#)); 8 57!(9 !);9 $, "#.9:";9 %%) 8 8 , "#/#[9]!?9.#<%9#(); 8 !"#$!% ,; } 开散列表 *+"%,-./0-12" 的成员查询函数 ,0A"=1"!(>,,)根据元素 ! 的散列函数值确定 存储该元素的桶号,然后调用相应的表定位函数返回查询结果。 9%# ,0A"=1"!(;"#<#"= >,*+"%,-./0-12" ,) { 9%# 9 !(!, "#/5)(>)’ , "#.9:"; !"#$!%(?9.#?76-#"(>,, "#/#[9])#)); } !"#第 B 章8 符8 号8 表 开散列表 !"#$%&’()&*+# 的元素插入运算 %),$’#-.(/,%)根据元素 ! 的散列函数值确定存 储该元素的桶号,然后在该桶的表首插入元素 !。 0123 %),$’#-.(4#.,.#5 /,!"#$%&’()&*+# %) { 2$. 2; 26(%)7#5*#-(/,%))8--1-(’9&3 ,$":.’); 2 !(!% "#(6)(/); % "#’2<#; =2’.,$’#-.(>,/,% "#(.[2]); } 开散列表 !"#$%&’()&*+# 的元素删除运算 %)?#+#.#(/,%)根据元素 ! 的散列函数值确定存 储该元素的桶号,再调用相应的表元素删除函数删除元素 !。 0123 %)?#+#.#(4#.,.#5 /,!"#$%&’()&*+# %) { 2$. 2,@; 2 !(!% "#(6)(/); % "#’2<#; 26(@ !=2’.=1A&.#(/,% "#(.[2]))=2’.?#+#.#(@,% "#(.[2]); } !" #" #$ 闭散列 闭散列表将符号表的元素直接存放在桶数组单元中,而不用桶数组来存放链表。因此闭散 列表中的每个桶都只能存放集合中的一个元素。当要把元素 ! 存放到桶 "(!)中,但发现这个桶 已被其他元素占用时,就发生了冲突。为了解决闭散列中的冲突,需要使用重新散列技术,使得 发生冲突时,按重新散列技术可以选取一个桶序列 "B(!),"C(!)⋯ 只要桶头数组尚未全部被占 用,顺序试探这个桶序列中各个桶,一定能找到一个空桶来存放元素 !。最简单的重新散列技术 是线性重新散列技术,即当散列函数为 "(!),桶数为 # 时,取 "$(!)%("(!)& $); #,$ % B,C,⋯ ,# ’ B D D 例如,设集合元素 (、)、* 和 + 的散列值分别为 "(()% E,"())% >,"(*)% F,"(+)% E。要将 这些元素散列到一个具有 G 个桶的闭散列表 , 中,发生冲突时用线性重新散列技术解决冲突。 假设初始时桶数组中每个单元都是空的,并在每个单元中存放一个特殊记号 #5".H,用来标记这 个单元为空。显然,( 可以存放在桶 E 中,) 可以存放在桶 > 中,* 可以存放在桶 F 中。当要往闭 散列表 , 中存放 + 时,发现 "(+)% E,且桶 E 中已经存放了元素 (,于是按线性重新散列技术试 探 "B(+)% F。因为桶 F 中也已存放了一个元素,所以按线性重新散列技术再试探 "C(+)% I,这 !"# 数据结构与算法 时桶 ! 是空的,所以将 ! 存放在桶 ! 中。此时闭散列表 " 中存放的元素如图 "# $ 所示。 图 "# $% 闭散列表 检测一个元素 # 是否在一个闭散列表中,只要顺序查看桶 $(#),$$(#),$&(#),⋯ 如果在某 个桶中找到 #,则 # 在这个闭散列表中。如果没有找到 # 而遇到一个空桶,是否可以断定 # 不在 这个闭散列表中?如果在这个闭散列表中没有执行过删除操作,可以断定 # 不在闭散列表中。 如果对这个闭散列表执行过删除操作,就无法确定所遇到的空桶在当初存放 # 时是否曾被占用, 因而也就无法确定 # 是否在闭散列表中。解决这个问题的一个有效方法是取另一个与 ’()*+ 不 同的特殊记号 ,’-’*’,,用来标记一个曾被占用过的空桶。当某个桶里的元素被删除时,就将这 个特殊记号 ,’-’*’, 存入该桶。这样,在一个执行过删除操作的闭散列表中作成员查询时,如果 遇到空桶就可以断定 # 不在这个闭散列表中。例如,若设 $(%)& .,要检测元素 % 是否在图 "# $ 所示的闭散列表 " 中,顺序查看了桶 .、! 和 /,结果没有找到元素 % 而遇到了一个空桶。由于图 "# $ 所示的闭散列表 " 上没有执行过删除操作,所以可以断定元素 % 不在这个闭散列表中。如 果在图 "# $ 所示的闭散列表 " 上连续执行 012’-’*’(3,0)和 014’(5’6(,,0)运算,则先将元素 ’ 从桶 . 中删去,并将特殊记号 ,’-’*’, 存入桶 . 中,然后从桶 $(!)& 7 开始,顺序查看桶 . 和桶 !,并在桶 ! 中找到了元素 !。 用闭散列表实现的符号表结构 089:185-’ 定义如下: *+)’,’; 9*6<3* :89:*85-’ !089:185-’; *+)’,’; 9*6<3* :89:*85-’{ % % % =>* 9=?’;% % % % % % &!桶数组大小 !& % % % =>*(!:;)(@’*A*’( B);&!散列函数 !& % % % @’*A*’( !:*; &!桶数组 !& % % % =>* !9*8*’; &!占用状态数组 !& }089:*85-’; 其中,:* 是桶数组;9=?’ 是桶数组的大小;数组 9*8*’ 用于表示桶单元的占用情况。当 9*8*’[C]的 值为 D 时,表示桶单元 :*[C]已被占用;当 9*8*’[C]的值为 $ 时,表示桶单元 :*[C]为空桶;当 9*8*’ [C]的值为 & 时,表示桶单元 :*[ C]曾被占用,但其中元素已被删除。:;( B)是元素 # 的散列 函数。 函数 01A>=*(,=E=9F6,:89:;(B))初始化桶数组 :* 和 9*8*’,将每个桶都设置为空桶。创建一个 空散列表。 089:185-’ 01A>=*(=>* ,=E=9F6,=>*(!:89:;)(@’*A*’( B)) { =>* =; !"#第 " 章% 符% 号% 表 !"#$%"&’( ! !)"’’*+(#,-(*. !!); ! "##,-( !/,0,#*1; ! "#$. !$"#$.; ! "#$2 !)"’’*+(! "##,-(!#,-(*.(3(242())); ! "##2"2( !)"’’*+(! "##,-(!#,-(*.(,52)); .*1(, !6;, $! "##,-(;, %%) 7 ! "##2"2([,]!8; 1(2915 !; } 函数 :,5/;"2+$(<,!)在散列表 ! 的桶数组中查找元素 !,并返回它在桶数组中的位置。当 ! 不在桶中时,函数的返回值为 ! = ##,-(。 ,52 :,5/;"2+$(3(242() <,!"#$%"&’( !) { ,52 ,,>,?; > !(!! "#$.)(<);7 7 7 &!初始桶 !& .*1(, !6;, $! "##,-(;, %%){ 7 ? !(> %!"#$@1*&(,))A ! "##,-(; 7 ,.(! "##2"2([?]!!8)&1("?; 7 ,.(!! "##2"2([?]BB ! "#$2[?]!!<)1(2915 ?; 7 } 1(2915 ! "##,-(; } 函数 :,5/;"2+$ 在桶数组中查找元素 ! 的过程中,用探测函数 !"#$@1*&(,)逐个扫描可能存 储元素 ! 的位置,直至找到元素 ! 或遇到空桶。为明确起见,这里给出的解决地址冲突的探测函 数 !"#$@1*&(,)是线性探测函数。 ,52 !"#$@1*&(,52 ,) { 1(2915 ,; } 函数 C5*++9D,(/(<,!)类似于函数 :,5/;"2+$,返回散列表 " 的桶数组中可存储元素 ! 的未 占用桶单元位置 #,即桶单元 $2[?]是空桶或桶单元 $2[?]曾被占用,但其中元素已被删除。当 找不到未占用桶单元时,函数的返回值为 ! = ##,-(,表明桶数组已满。 !!" 数据结构与算法 !"# $"%&&’(!)*(+)#,#)- .,/0123045) /) { !"# !,6,7; 6 !(!/ "#28)(.);&!初始桶 !& 8%9(! !:;! $/ "#1!;);! %%){ < 7 !(6 %/012=9%4(!))> / "#1!;); < !8(/ "#1#0#)[7])9)#’9" 7; < } 9)#’9" / "#1!;); } 闭散列表 /0123045) 通过调用函数 ?!"*@0#&2 实现成员查询函数 /3@)-4)9(.,/)。 !"# /3@)-4)9(+)#,#)- .,/0123045) /) { !"# ! !?!"*@0#&2(.,/); !8(! $/ "#1!;) AA / "#2#[!]!!.)9)#’9" B; 9)#’9" :; } 函数 /3,"1)9#(.,/)先调用函数 /3@)-4)9,确定元素 ! 不在散列表 " 中后,再用函数 $"%&C &’(!)* 计算出元素 ! 在桶数组中的可插入位置,并在此位置插入元素 !。 D%!* /3,"1)9#(+)#,#)- .,/0123045) /) { !"# !; !8(/3@)-4)9(.,/))E99%9(’F0* ,"(’#’); ! !$"%&&’(!)*(.,/); !8(! $/ "#1!;)){ < < / "#1#0#)[!]!:; < < / "#2#[!]!.; < < } )51) E99%9(’#045) 8’55’);&!桶数组已满 !& } 函数 /3G)5)#)(.,/)在 ?!"*@0#&2 找到元素 ! 所在的桶数组单元 # 后,将该单元所对应的状 态 1#0#)[!]的值置为 H,表明 2#[!]中元素 ! 已被删除。 !"#第 I 章< 符< 号< 表 !"#$ %&’()(*((+(*,*(- .,%/01&/2)( %) { #3* # !4#3$5/*61(.,%); #7(# $% "#0#8( 99 % "#1*[#]!!.)% "#0*/*([#]!:; } 上述删除元素算法 %&’()(*( 的一个明显不足是在对散列表执行了大量元素删除运算后,在 散列表中查询的速度减慢。其主要原因是在执行查询运算时,散列表中元素已被删除的桶单元 被当作非空桶单元来处理。实现删除运算的另一种策略是在删除一个元素后,用当前桶中另一 个元素来填充被删除元素释放的桶空间。假设被删除元素 ! 位于桶单元 1*[#]。现考察一个非 空桶单元 1*[;]中的元素 ",其散列函数值为 1 < 17[=]。下面分情况讨论可用非空桶单元1*[;] 中的元素 = 填充被删除元素释放的桶空间 1*[#]的条件。 ! 当 # $ % 时,若 # $ &"% 则不可用元素 " 填充 1*[#]。当 &"# $ % 或 # $ % $ & 时可用元素 " 填 充 1*[#],如图 >? :/ 所示。 " 当 % $ # 时,仅当 % $ &"# 时可用元素 " 填充 1*[#],如图 >? :2 所示。 图 >? :@ 填充删除的条件 用上述思想实现的删除运算 %&’()(*(A(.,%)描述如下: !"#$ %&’()(*(A(+(*,*(- .,%/01&/2)( %) { #3* #,;,B; # !4#3$5/*61(.,%); #7(# $% "#0#8( 99 % "#1*[#]!!.){ @ 7"C(;;){ @ @ % "#0*/*([#]!A; @ @ @ 7"C(; !(# %A)D % "#0#8(;!% "#0*/*([;];; !(; %A)D % "#0#8(){ @ @ @ @ B !(!% "#17)(% "#1*[;]); @ @ @ @ #7((B $!# 99 # $;)(((# $; 99 ; $B)(((; $B 99 B $< #))2C(/B; @ @ @ @ } @ @ @ #7(% "#0*/*([;])2C(/B; @ @ % "#1*[#]!% "#1*[;]; @ @ @ % "#0*/*([#]!% "#0*/*([;]; @ @ @ # !;; @ @ } @ } } !"# 数据结构与算法 !" #" $% 散列函数及其效率 要在常数时间内实现符号表各运算的关键在于选择一个好的散列函数,它能将集合中的 ! 个元素均匀地散列到 " 个桶中,这样每个桶中平均有 ! # " 个元素。在开散列表中,!"#$%&’(、!") *&+&(& 和 !",&-.&’ 运算就只要 $ !( )" 平均时间。当 ! # " 为一常数时,每个符号表运算可在常 数时间内完成。 下面介绍几种计算简单且效果较好的散列函数构造方法。 (/)除余法 选择一个适当的正整数 %,用 % 去除键值,取所得的余数作为散列函数值,即 &(’)( ’0 %。 这个方法的关键是选取适当的 %,当然 % 不能超过桶数 "。有时为了简单起见就取 % ( "。这样 当 " 为偶数时,总是将奇数键值转换为奇数散列值,将偶数键值转换为偶数散列值,这当然不 好。另外,如果 " 是键值基数的幂次时,取 % ( " 就等价于将键值的最后几位数字作为散列值。 例如,若键值是十进制数,而 " ( /11,则实际上就是取键值的最后 2 位数作为散列值。一般地选 % 为不超过 " 的最大素数比较好。 由于除余法散列函数计算公式简单,而且在许多情况下效果较好,因此是最常用的构造散列 函数的方法。 (2)数乘法 用数乘法构造散列函数是先选择一个纯小数 ),1 3 ) * /,然后对于键值 ’ 和散列表的桶数 ",构造相应的散列函数值为 &(’)( "(’) + ’),- ),- 4 4 数乘法的一个优点是构造出的散列函数值在散列表中分布的均匀性不依赖于桶数 "。虽然 该方法中的纯小数 ) 可以任意选择,但它的选择会影响散列函数的分布均匀性。最优的选择依 赖于集合中元素键的数字特征。在一般情况下,选择 ) ( !5 + / 2 ( 16 7/8 ⋯ 会使散列函数值在 散列表中的分布比较均匀。 (9)平方取中法 平方取中法是较随机的一种散列方法。一般地,当桶数 " 不是 /1 的方幂,而键是 1 : ! 之间 的整数时,可以选取整数 .,使得 ".2 与 !2 大致相等,然后令 &(’)( ’2 # .,- 0 ",则 &(’)就是 ’2 的中间数字,且不超过 "。当 " 和 . 均为偶数时,散列的效果往往不太好,因此,可选 . 与 " 互素。 (;)基数转换法 基数转换法是将键值看成用另一个进制表示的数后,再将它转换为原来进制表示的数,取其 中若干位作为散列函数值。一般取大于原来基数的数作为转换的基数,并且这 2 个基数是互 素的。 (5)随机数法 选择一个随机函数作为散列函数,取键的随机函数值作为它的散列函数值,即 <(=)> ’?$) !"#第 @ 章4 符4 号4 表 !"#($)。其中 %&’!"# 为随机函数。通常,当键的长度不等时,采用此法构造散列函数效果 较好。 !" #" $% 闭散列的重新散列技术 在闭散列中,插入及其他运算所耗费的时间不仅依赖于散列函数的选取,而且与重新散列技 术有关。采用线性重新散列技术不可避免地会出现散列表中元素的“聚集”现象,即散列表中成 块的连续地址被占用。为了减少聚集的机会,应该采用跳跃式的重新散列技术。下面介绍另外 ( 种散列技术,它们大大减少了元素聚集的可能性。 ())二次重新散列技术 二次重新散列技术选取的探查桶序列为 !("),!)("),!*("),⋯ ,!*#$)("),!*#("),⋯ 其中,!*#$)(")%(!(")& #* )+ ’,!*#(")%(!(")$ #* )+ ’。 虽然二次重新散列减少了元素聚集的可能性,但用此方法不易探查到整个散列表。只有当 ’ 为形如 ,( & ( 的素数时,才能探查到整个散列表。 (*)随机重新散列技术 随机重新散列技术选取的探查序列为 !#(")%(!(")& )# )+ ’,# % ) ,*,⋯ ,’ $ )。其中, )) ,)* ,⋯ ,)’ $ ) 是 ),*,⋯ ,’ $ ) 的一个随机排列。如何得到随机排列,涉及随机数的产生问题。 在实际应用中,常用移位寄存器序列代替随机数序列。 (()双重散列技术 这种方法使用 * 个散列函数 ! 和 !-来产生探索序列: !#(")%(!(")& #!*("))+ ’,+ # % ),*,⋯ ,’ $ ) . . 定义 !*(")的方法较多,但无论采用什么方法定义 !*,都必须使 !*(")的值和 ’ 互素才能使 散列函数值在散列表中均匀分布。 /0 (. 应. . 用 散列表方法可以用来统计文本文件中字符串出现的频率。计算存储字符串地址的散列函数 定义为: 1’2 3&43)(53&%!6) { 1’2 78’,1,9 !:; 78’ !42%78’(6); ;"%(1 !:;1 $78’ ;1 %%) . . 9 % !6[1]; %82<%’ 9 + #&641=8; !"# 数据结构与算法 } !"# $%&$(’%()*)+,-. )/)() { -)#0-" $%&$1()/)( "#"%()); } 用此散列函数将文本文件中所有字符串存入一个闭散列表中。该闭散列表中元素类型定 义为: #23).)4 &#-0+# ",.) !’%()*)+,-.; #23).)4 &#-0+# ",.){ 5 5 5 +$%- !"%(); 5 5 5 !"# +,0"#; 5 5 }’%()",.); ’%()*)+,-. ’)6’%()(+$%- !&) { ’%()*)+,-. 3; !4((3 !(%//,+(&!7),4(!3)))!!8) 5 5 9--,-(’9:$%0&#). ()(,-2; ’); 3 "#"%() !(%//,+(&#-/)"(&)!&!7),4(+$%-)); &#-+32(3 "#"%(),&); 3 "#+,0"# !1; -)#0-" 3; } 其中,"%() 用于存储字符串;+,0"# 存储字符串的出现频率。’)6’%()(&)创建一个存储字符串 ! 的新元素。 下面的算法 &#-4-)< 通过依次扫描文本文件中每个字符串来统计各字符串的出现频率。当扫 描的字符串是第一次遇到的新字符串时,创建一个存储该字符串的新元素,并将该元素存入散列 表中。当扫描的字符串已在散列表中时,存储该字符串元素的 +,0"# 值增加 1。文件扫描结束 后,散列表中各元素的 +,0"# 值即为该元素相应的字符串 "%() 在文本文件中出现的频率。 =,!. &#-4-)<(+$%-!4"%()) { !"# !; >?@9!43; !"!第 A 章5 符5 号5 表 !"#$%$&’() ($&,*+(; &,"( *[-..]; /"*,0"12$ /3 !/0456+(#"7*68$,,"*,); 9: !9’:$5(95"#$,’(’); 69(!9:);((’((’<’=2) 5’+ ’:$5 +,$ 962$> ’); *+( !!$?!"#$(*); ?,62$(!9$’9(9:)) { @ @ 9*&"59(9:,’A *’,*+( "#5"#$); @ @ 6 !365)B"+&,(*+(,/3); @ @ 69(6 $/3 "#*68$)/3 "#,+[6]"#&’=5+ %%; @ @ $2*${($& !!$?!"#$(*+( "#5"#$);/045*$(+(($&,/3);} } C=+:=+(/3); } 本 章 小 结 本章讲授的主题是抽象数据类型符号表及其实现方法。符号表是常用的以集合为基础,并 支持 D$+B$#1$(、D$+45*$(+ 和 D$+E$2$+$ 这 F 种运算的抽象数据类型。本章讨论了用数组实现符号 表的方法。散列表是实践中常用实现符号表的方法,也是本章的重点。不论是开散列表还是闭 散列表,要在常数时间内实现符号表各运算的关键在于选择一个好的散列函数,它能将集合中的 元素均匀地散列到各个桶中。本章介绍了除余法、数乘法、平方取中法、基数转换法和随机数法 等实践中常用的散列函数构造方法。在闭散列表中,解决冲突的重新散列技术直接影响算法的 效率。本章还介绍了线性重新散列技术、二次重新散列技术、随机重新散列技术和双重散列技术 等实践中常用的闭散列表的重新散列技术。 习@ @ 题 !" # $ 设散列函数为 !(") # " A G,问: (-)将完全立方数 -,H,IG,JK,-IL,I-J,FKF 插入到一个初始为空的开散列表中,结果如何? (I)用线性重新散列技术解决冲突,将(-)中的数插入到一个闭散列表中,结果如何? !" %$ 设 #$.#,#$-#,⋯ ,#$MM# 是 -.. 个字符串,若用散列函数 !(%)#($’()(%" ))A-.. 将这 -.. 个字符串散 列到一个由 -.. 个桶组成的开散列表中,并假定 ’()(.),’()(-),⋯ ,’()(M)组成一个等差数列,那么这 -.. 个字 符串最多能被散列到多少个桶中?其中含字符串最多的桶中有多少个字符串? !" &$ 当一个 $ 桶开散列表中存放的元素已经超过 $ 个时,可以重建一个 & 桶开散列表。写出从旧散列表 构造新散列表的算法。将每一个桶看成一个表,用类 N6*+ 的各种运算处理每一个桶。 !" ’$ 随机散列函数 !"(’)#(!(’)( )" )A &," O -,I,⋯ ,& P -,要求 )- ,)I ,⋯ ,)& * - 是 -,I,⋯ ,& P - 的随机 !"# 数据结构与算法 排列。当 ! " !# ,# " #,试选取合适的 $(#" $ "! % #),使得对于任意 &#(#"&# "! % #),由下式确定的 &# , &! ,⋯ ,&! % # 组成 #,!,⋯ ,! % # 的一个伪随机排列。 &’ " !&’%# ( ( ( ( ( ( ( ( !&’%# ) ! (!&’%# % !)% $( ( ( !&’%# & { ! 其中,%是按位模 ! 加法。 设 ! " #$ ,求出使 &# ,&! ,⋯ ,&#% 是 #,!,⋯ ,#% 的一个排列的所有可能的 $ 值。 !" #$ 试写一个程序,随机生成 #&% 个不超过 #&$ 的非负整数。用开散列和闭散列 ! 种方法将这 #&% 个数插 入散列表中,并比较 ! 种方法的效率。 !" %$ 分别用二次重新散列技术、随机重新散列技术和双重散列技术这 ’ 种方法实现闭散列表。 !"#第 ( 章) 符) 号) 表 书书书 第 !" 章# 字# # 典 学习目标 · 理解以有序集为基础的抽象数据类型字典。 · 理解用数组实现字典的方法。 · 理解二叉搜索树的概念和实现方法。 · 掌握用二叉搜索树实现字典的方法。 · 理解 !"# 树的定义和性质。 · 掌握二叉搜索树的结点旋转变换及实现方法。 · 掌握 !"# 树的插入重新平衡运算及实现方法。 · 掌握 !"# 树的删除重新平衡运算及实现方法。 $%& $’ 字典的定义 当集合中的元素有一个线性序,即全集合是一个有序集时,往往要涉及与这个线性序有关的 一些集合运算。例如对于集合 ! 中的一个元素 ",找它在集合 ! 中按照线性序排列的前驱元素 或后继元素的运算。用符号表表示集合时,这类运算较难实现或实现的效率不高。为此引入另 一个抽象数据类型字典。字典中元素有一个线性序,且支持涉及线性序的一些集合运算。 字典是以有序集为基础的抽象数据类型,它支持以下运算: ! ()*+),(-,.),成员运算。 " /01),2(-,.),插入运算:将元素 " 插入集合 !。 # 3)4)2)(-,.),删除运算:将元素 " 从当前集合 ! 中删去。 $ 5,)6)7)118,(-,.),前驱运算:返回集合 ! 中小于 " 的最大元素。 % .977)118,(-,.),后继运算:返回集合 ! 中大于 " 的最小元素。 & :;0<)(-,=,.),区间查询运算:返回集合 ! 中界于 " 和 # 之间的所有元素组成的集合。 ’ (>0(.),最小元运算:返回当前集合 ! 中依线性序最小的元素。 !"# $% 用数组实现字典 用数组实现字典与用数组实现符号表的不同之处在于可以利用线性序将字典中的元素从小 到大依序存储在数组中,用数组下标的序关系来反映字典元素之间的序关系,从而有效地实现与 线性序有关的一些运算。例如,在这种表示法下,可用二分查找算法来实现 &’()’* 运算。它每 次将搜索区间长度缩小一半,因此若字典中有 ! 个元素,则在最坏情况下所需的搜索时间为 "(+,- !)。类似地,前驱运算 .*’/’0’11,*(2,3)和后继运算 3400’11,*(2,3)也可在 "(+,- !)时间 内实现。 要实现 567-’(2,8,3)只要先找到元素 # 的前驱元素 .*’/’0’11,*(2,3)和元素 8 的后继元素 3400’11,*(8,3),界于 .*’/’0’11,*(2,3)和 3400’11,*(8,3)之间的元素为 567-’(2,8,3)中元素。 因此若用 567-’(2,8,3)找到的集合 $ 中的 % 个元素,则它需要的计算时间为 "(% 9 +,- !)。 用数组来实现字典的一个明显的缺陷是插入和删除运算的效率较低。为了维持字典元素在 数组中依序存储,每执行一次 :71’*; 或 <’+’;’ 运算,需要移动部分数组元素,从而导致它们在最 坏情况下的计算时间为 "(!)。 !"# =% 用二叉搜索树实现字典 用数组实现字典可以使 &’()’* 运算效率较高,但 :71’*; 和 <’+’;’ 运算的效率不高。若用链 表来实现字典,则情况正好相反。此时,&’()’* 运算只能通过对链表的顺序搜索来实现,因此需 要 "(!)的计算时间。而一旦找到元素在链表中插入或删除的位置后,只要用 "(!)时间就可完 成插入或删除操作。为了利用数组和链表二者的优点,引入二叉搜索树,并用它来实现字典。 二叉搜索树利用树的结点来存储有序集中的元素,它具有下述性质:存储于每个结点中的元 素 # 大于其左子树中任一结点中所存储的元素,小于其右子树中任一结点中所存储的元素。 图 !"# ! 给出了两棵二叉搜索树,它们表示相同的整数集合{>,?,!",!$,!@,!>,!A}。 图 !"# !% 两棵二叉搜索树 如果按照中序列出二叉搜索树结点中所存储的元素,则恰好是集合中的所有元素从小到大 !"#第 !" 章% 字% % 典 的排列。 用二叉搜索树实现字典时,其结点的类型是对第 ! 章中讨论的二叉树结点类型 "#$%&’ 的扩 充。其中增加了指向当前结点的父结点的指针 ()*’$#,这是为了便于算法实现对树结点操作。 若在算法中用指针变量来记录当前结点的父结点,也可将 ()*’$# 指针域省去。 #+(’&’, -#*./# 0#$%&’ !0#12$3; #+(’&’, -#*./# 0#$%&’ { 4 4 5*’’6#’7 ’1’7’$#; 4 4 2$# 0)1;4 4 4 4 4 !!结点平衡因子 !! 4 4 0#12$3 1’,#; !!左儿子结点指针 !! 4 4 0#12$3 *289#; !!右儿子结点指针 !! 4 4 0#12$3 ()*’$#; !!父结点指针 !! 4 4 }"#$%&’; 0#12$3 :’;"<$%&’(5*’’6#’7 =) { 4 4 0#12$3 *; 4 4 2,((* "7)11%/(-2>’%,("#$%&’))) ""?)@**%*(#@=9).-#’& 7’7%*+A #); 4 4 * $%’1’7’$# "=; 4 4 * $%0)1 "?; 4 4 * $%1’,# "?; 4 4 * $%*289# "?; 4 4 * $%()*’$# "?; 4 4 *’#.*$ *; } 函数 :’;"<$%&’(=)产生一个存储元素 ! 的新二叉树结点。 另外,用二叉搜索树实现字典时,其结点中存储的是有序集中的元素,需要定义元素间的序 关系。例如对于整数集可以定义有序集元素 5*’’6#’7 如下: #+(’&’, 2$# 5*’’6#’7; #+(’&’, 5*’’6#’7!5*’’)&&*; B&’,2$’ 3’+(C)(C) B&’,2$’ 1’--(C,")(3’+(C) &3’+(")) B&’,2$’ ’D(C,")(!1’--(C,")EE !1’--(",C)) B&’,2$’ -;)((C,"){6#’7 # "C;C "";" "#;} !"# 数据结构与算法 !"#$ %&’’()’*+,"-(%&’’()’* .) { / / 0)2(# 3 $’1#,.); } 对二叉树结点进行扩充后,二叉搜索树结构 4+)&’’ 定义为: )50’$’2 6)&78) 96)&’’ !4#1:&5%&’’; )50’$’2 6)&78) 96)&’’{ / / 9);#1< &"");!!根结点指针 !! }4+)&’’; 在用二叉搜索树表示的字典 ! 中搜索一个元素 " 时,首先将树根 &"") 作为当前考察的结点 #。将元素 " 与当前结点中存储的元素 0 $%’;’*’1) 进行比较,如果 . = 0 $%’;’*’1),则 " 属于 !;如 果 . &0 $%’;’*’1),则 " 属于 ! 当且仅当 " 存储于 # 的左子树 0 $%;’2) 中,此时可将当前考察的结点 置为 0 $%;’2),继续比较;如果 . %0 $%’;’*’1),则 " 属于 ! 当且仅当 " 存储于 # 的右子树0 $%&#>,)中, 此时可将当前考察的结点置为 0 $%&#>,),继续比较。下面的算法 4++’:&8,(.,%)实现了这一搜索 过程。 9);#1< 4++’:&8,(%&’’()’* .,4#1:&5%&’’ %) { / / 9);#1< 0 "% $%&""); / / -,#;’(0) / / / #2(;’66(.,0 $%’;’*’1)))0 "0 $%;’2); / / / ’;6’ #2(;’66(0 $%’;’*’1),.))0 "0 $%&#>,); / / / ’;6’ 9&’:<; / / &’)7&1 0; } 成员查询函数 4+?’*9’&(.,%)只要返回 4++’:&8,(.,%)搜索的结果。 #1) 4+?’*9’&(%&’’()’* .,4#1:&5%&’’ %) { / / &’)7&1 4++’:&8,(.,%)?@:A; } 在二叉搜索树 ! 中插入一个元素 " 的运算 4+(16’&)(.,%)可实现如下: !!"第 @A 章/ 字/ / 典 !"#$%& ’()%*+,"(-,++)"+. /,’$%0,1-,++ -) { 2 2 !"#$%& 3,,,33 "4;2 2 2 !!33 是 3 的父结点指针 !! 2 2 3 "- $%,55"; !!3 为搜索指针 !! 2 2 !!搜索插入位置 !! 2 2 67$#+(3){!!考察当前结点中存储的元素 3 $%+#+.+%" !! 2 2 2 33 "3; 2 2 2 !!选择搜索子树 !! 2 2 2 $8(#+**(/,3 $%+#+.+%"))3 "3 $%#+8"; 2 2 2 +#*+ $8(#+**(3 $%+#+.+%",/))3 "3 $%,$97"; 2 2 2 2 2 +#*+ ,+":,% 4; 2 2 2 } 2 2 !!新结点 !! 2 2 , ";+6’(%5<+(/); 2 2 $8(- $%,55"){!!当前树非空 !! 2 2 2 $8(#+**(/,33 $%+#+.+%"))33 $%#+8" ",; 2 2 2 +#*+ 33 $%,$97" ",; 2 2 2 , $%30,+%" "33; 2 2 2 )%*+,"=+!0#(,,-);2 2 2 !!重新平衡 !! 2 2 2 } 2 2 +#*+ - $%,55" ",; !!插入空树 !! 2 2 ,+":,% ,; } 上述算法用类似于 ’((+0,>7(/,-)的方法搜索元素 !,当找到一个空指针时,就在这个位置 插入一新结点,而将元素 ! 存储于这个新结点中。其中 )%*+,"=+!0#(,,-)是对插入一个结点后的 树 " 进行重新平衡运算,在介绍平衡二叉搜索树时讨论。上述算法在搜索过程中发现一个结点 存储的元素是 !,则结束搜索。此时元素 ! 已经在集合中,不必对二叉搜索树作任何改动。有时 在插入过程中需要以某种方式访问已经在集合中的元素 !,仅当 ! 不在集合中时才插入元素 !。 实现这一个功能的算法 ’()%*+,"?$*$"(/,@$*$"(:),-)如下: !"#$%& ’()%*+,"?$*$"(-,++)"+. /,@5$<(!@$*$")(-,++)"+. :),’$%0,1-,++ -) { 2 2 !"#$%& 3,,,33 "4;2 2 2 !!33 是 3 的父结点指针 !! 2 2 3 "- $%,55"; !!3 为搜索指针 !! 2 2 !!搜索插入位置 !! 2 2 67$#+(3){!!考察当前结点中存储的元素 3 $%+#+.+%" !! 2 2 2 33 "3; !"# 数据结构与算法 ! ! ! !!选择搜索子树 !! ! ! ! "#($%&&(’,( $%%$%)%*+))( "( $%$%#+; ! ! ! %$&% "#($%&&(( $%%$%)%*+,’))( "( $%,"-.+; ! ! ! %$&% {/"&"+(( $%%$%)%*+);,%+0,* (;} ! ! ! } ! ! !!新结点 !! ! ! , "1%234*56%(’); ! ! "#(7 $%,55+){!!当前树非空 !! ! ! ! "#($%&&(’,(( $%%$%)%*+))(( $%$%#+ ",; ! ! ! %$&% (( $%,"-.+ ",; ! ! ! , $%(8,%*+ "((; ! ! ! 9*&%,+:%;8$(,,7);! ! ! !!重新平衡 !! ! ! ! } ! ! %$&% 7 $%,55+ ",; !!插入空树 !! ! ! ,%+0,* ,; } 从二叉搜索树 ! 中删除一个元素 " 稍复杂。首先必须找到存储元素 " 的结点。如果这个结 点是一个叶结点,只要删除这个叶结点就行了。如果要删除的结点不是一个叶结点,就不能简单 地删除这个结点,因为这样做将破坏树的连通性。如果要删除的结点 # 只有一个儿子结点,例 如,图 <=> <; 中存储元素 <8 中存储元素 <= 的结点,为了保持二叉搜索树的性质,即按中序遍历树结点将从小到 大排列出所有结点中元素,可以用 # 的前驱结点或后继结点来代替它。例如,在图 <=> <8 中删去 元素 <=,应该先删去元素 <@,并用 <@ 代替 <= 的位置。这样删去一个元素后得到的树仍是一棵 二叉搜索树。下面的算法 34A%$%+%(’,7)实现了上面所讨论的删除元素的过程。 ;+$"*B 34A%$%+%(7,%%9+%) ’,3"*8,C7,%% 7) { ! ! ;+$"*B D,(,&,(&,(( "=;!!(( 是 ( 的父结点指针 !! ! ! ( "7 $%,55+; !!( 为搜索指针 !! ! ! 2."$%(( EE !%F(( $%%$%)%*+,’)){!!搜索要删除的结点 !! ! ! ! (( "(; ! ! ! "#($%&&(’,( $%%$%)%*+))( "( $%$%#+; ! ! ! %$&% ( "( $%,"-.+; ! ! ! } ! ! "#(!(),%+0,* =; ! ! "#(( $%$%#+ EE ( $%,"-.+){!!( 有 @ 个儿子结点 !! ! ! ! !!搜索 ( 的左子树中的最大元素 !! !"#第 <= 章! 字! ! 典 ! ! ! " "# $%$%&’;#" "#; ! ! ! ()*$%(" $%+*,)’){ ! ! ! ! ! #" ""; ! ! ! ! ! " "" $%+*,)’;} ! ! ! !!用 " 中的元素替换 # 中的元素 !! ! ! ! # $%%$%-%.’ "" $%%$%-%.’; ! ! ! # ""; ! ! ! ## "#";} ! ! !!# 最多只有 / 个儿子结点 !! ! ! *&(# $%$%&’)0 "# $%$%&’; ! ! %$"% 0 "# $%+*,)’; ! ! !!删除结点 # !! ! ! *&(# ""1 $%+22’){ ! ! ! 1 $%+22’ "0; ! ! ! *&(0)0 $%#3+%.’ "4;} ! ! %$"% {!!确定 # 是其父结点的左儿子结点还是右儿子结点 !! ! ! ! ! *&(# ""## $%$%&’){ ! ! ! ! ! ## $%$%&’ "0; ! ! ! ! ! # $%$%&’ "#;!!这 / 步为重新平衡作准备 !! ! ! ! ! ! } ! ! ! ! %$"% ## $%+*,)’ "0; ! ! ! ! *&(0)0 $%#3+%.’ "# $%#3+%.’; ! ! ! ! } ! ! 5%$%’%6%73$(0,#,1);!!重新平衡 !! ! ! &+%%(#); ! ! +%’8+. 0; } 下面讨论用二叉搜索树实现字典的效率。如果 ! 个结点的二叉搜索树是一棵近似满二叉 树,那么从根结点到任一叶结点的路径上至多有 / 9 $2, !:; 个结点。于是 <=>%-7%+、<=?."%+’、 <=5%$%’% 诸运算所需时间均为 "($2, !)。这是因为上述算法在每个结点处只耗费了 "(/)时间, 而整个算法所访问的结点组成一条从根结点出发的路径。这条路径的长度为 "($2, !),从而总 的计算时间为 "($2, !)。 将 ! 个随机的元素插入到一棵空树中去时,并不一定总能得到一棵近似满二叉树。例如,将 ! 个元素按从小到大的顺序插入一棵空二叉树中,得到一棵退化的二叉搜索树,即一条链。除了最底 层的叶结点外,每个结点都只有一个非空的右儿子结点。在这种情况下,插入第 # 个元素所需要的 时间为 "(#),从而插入这 ! 个元素所需时间为 " ! ! # "( ( )# @ "(!A ),每一次插入平均用时 "(!)。 从上面的分析可以看出,二叉搜索树的效率取决于它的高度。近似满二叉搜索树的高度为 !"# 数据结构与算法 !(!"# "),而退化的线性二叉搜索树的高度为 !(")。那么在平均情况下,二叉搜索树的高度是 接近于 !"# " 还是接近于 " 呢?假设二叉搜索树是从空树开始反复调用 $%&’()*+ 插入元素而得 到的,而且被插入的 " 个元素的所有可能的顺序是等概率的。在这个假设下,计算从树根到一个 随机结点的平均路长 #("),其中 " 为二叉搜索树中结点个数。 显然 #(,)- ,,#(.)- .。设 ""/,这 " 个元素按照插入的顺序组成一个表,将表中元素依 次逐个插入到空树中去而得到二叉搜索树。表中第一个元素 $ 存储于二叉搜索树的根结点中, 它是最小元,次小元,⋯ ,最大元的概率是相等的。设表中有 % 个元素小于 $,从而有 " & % & . 个 元素大于 $。显然,这样得到的二叉搜索树根结点中存储元素 $,% 个较小的元素存储在树根的左 子树中,其余 " & % & . 个元素存储在树根的右子树中。由于 % 个小元素和 " & % & . 个大元素的各 种顺序都是等可能的,所以树根的左子树和右子树的平均路长分别为 #(%)和 #(" & % & .)。在整 棵树中路长是从树根算起的,所以整棵树中每条路长将比子树中的相应路长多 .。因此,在根结 点左子树中有 % 个元素时的平均路长为 ’(",%)( . "(. ) %(#(%)) .))(" & % & .)(. ) #(" & % & .))) 根结点的左子树中有 ,,.,⋯ ," 0 . 个元素的情况是等可能的,因此二叉搜索树的平均路 长为: #(")( . " ! "&. % ( , ’(",%( )) ( . ) . "/ ! "&. % ( , (%#(%))(" & % & .)#(" & % & .)) ( . ) / "/ ! "&. % ( , %#(%) 对 " 用数学归纳法可以证明 #(")# . ) 1 !"# " 。事实上,当 " - . 时显然成立。若设 % * " 时有 #(%)# . ) 1 !"# % ,则 #(")# . ) / "/ ! "&. % ( . %(. ) 1 !"# %) # . ) / "/ ! "&. % ( . 1% !"# % ) / "/ ! "&. % ( . % # / ) 2 "/ ! "&. % ( . % !"# % # / ) 2 "/ ! "+ / &. % ( . % !"#(" + /)) ! "&. % ( "+ / % !"#( )" # / ) 2 "/ "/ 2 !"#(" + /)) 3"/ 2 !"#( )" ( / ) 2 "/ "/ / !"# " & "/( )2 ( . ) 1 !"# " 由数学归纳法即知,在随机插入所产生的二叉搜索树中,从树根到一个随机结点的平均路长 !"!第 ., 章4 字4 4 典 为 !(!"# ")。类似的分析可推出随机二叉搜索树的平均高度亦为 !(!"# ")。由此可知,用二叉 搜索树实现字典时,$%&’()’*、$%+,-’*.、$%/’!’.’ 等运算的平均时间为 !(!"# ")。 在二叉搜索树中,实现运算 0*’1’2’--"* 和 %322’--"* 的算法类似于 $%%’4*25 算法。例如,要 找元素 # 的后继元素时,与 $%%’4*25 算法一样从二叉搜索树的根结点开始,将 # 与存储在根结点 中的元素 $ 进行比较,当 #"$ 时,继续到根结点的右子树中去找元素 # 的后继元素;当 # % $ 时, 则 $ 是 # 的后继元素的候选者。如果 $ 不是 # 的后继元素,则由二叉搜索树的性质知 # 的后继 元素必在根结点的左子树中。于是将 $ 记为最新候选者,并继续到左子树中去搜索 # 的后继元 素,依次记住最新候选者,直至找到元素 # 的后继元素。容易看出 %322’--"* 与 $%%’4*25 有相同 的计算时间复杂性,即在最坏情况下需要 !(")计算时间,而在平均情况下,需要 !(!"# ")计算 时间。 64,#’ 运算可借助于 $%%’4*25 和 %322’--"* 运算来实现。给定 7 个元素 $#&,64,#’(8,9)运 算要找出存储在二叉搜索树中满足 $###& 的所有元素 #。首先,用 $%%’4*25(8,:)检测 $ 是否 在二叉搜索树 ’ 中,是则输出 $,否则不输出 $。然后从 $ 开始,不断地用 %322’--"* 找当前元素在 二叉搜索树中的后继元素。当找出的后继元素 # 满足 ##& 时,就输出 #,并将 # 作为当前元素。 重复这个过程,直到找出的当前元素的后继元素大于 &,或二叉搜索树中已没有后继元素为止。 这样,如果二叉树搜索树中有 ( 个元素 # 满足 $###&,则在最坏情况下用 !((")时间,在平均情 况下用 !(( !"# ")时间可实现 64,#’ 运算。 如果使用线索二叉树搜索树,则可在最坏情况下用 !(( ) ")和平均情况下用 !(( ; !"# ")时 间实现 64,#’ 运算。这个方法实现 64,#’ 运算的算法留作习题。下面介绍一个不使用线索二叉 搜索树,就能在上述时间界内完成 64,#’ 运算的方法。首先考虑半无限查询区域[$,) * )的情 形,即找出二叉搜索树中满足 $## 的所有元素 #。第 < 步还是在二叉搜索树中搜索元素 $。搜 索过程产生二叉搜索树中从根结点开始的一条路径。此时可能有 7 种情况。当 $ 不在二叉搜索 树中时,产生一条从根到叶的路径,如图 <=> 74 所示;当 $ 在二叉搜索树中时,产生一条从根到存 储元素 $ 的结点的路径。如图 <=> 7) 所示。 图 <=> 7? 在二叉搜索树中搜索 在最坏情况下,搜索过程所用的时间为!(+), 其中 + 为二叉搜索树的高度。在找到的搜索路径 上的所有结点可分为以下 @ 种情况,如图 <=> @ 所示。 在图 <=> @4 中,$ 小于结点中存储的元素 #,因 此,# 落在区域[$,) * )中,且该结点的右子树中 所有元素也都落在[$,) * )中。在图 <=> @) 中, $ , #,因此,# 不属于[$,) * ),且该结点左子树 中所有元素都不属于[$,) * )。在图 <=> @2 中, $ - #,因此 # 以及右子树中所有元素都落在区域[$,) * )中。由此可知,在搜索路径上,若一个 结点中存储的元素属于[$,) * ),则其右子树中所有元素都属于[$,) * )。因此,只要输出 搜索路径上属于[$,) * )的所有元素及其右子树中的所有元素,即可找出二叉搜索树中所有 落在查询区域[$,) * )中的元素。遍历一棵有 . 个结点的子树所需的时间为 !(.)。因此上 述算法在平均情况下所需的时间为 !(( ; !"# ")。如果只要求知道可以在哪里找到[$,) * )中 !"# 数据结构与算法 图 !"# $% 搜索路径上的结点分类 的元素,则只要输出上述搜索路径上存储[!," # )中元素的结点序列即可。在平均情况下,这 种结点有 $(&’( %)个,因此只需要 $(&’( %)时间即可实现。 现在回到原来的问题,即查询区域为[!,&]的情形。此时可用类似于上述算法的思想来实现 )*+(, 运算,所不同的是结点分类的情况更多些,见图 !"# -。 图 !"# -% 查询区域为[!,&]时结点分类情况 第 ! 步还是从二叉搜索树的根结点开始,同时搜索 ! 和 &。在搜索过程中遇到的结点可分为 图 !"# - 所示的 . 种类型。由图 !"# -* 可判定查询区域中所有元素在结点的左子树中;由图 !"# -/ 可判定查询区域中所有元素在结点的右子树中。图 !"# -* 和图 !"# -/ 的搜索可能一直进 图 !"# .% )*+(, 运算的搜索路径 行到叶结点,从而确定二叉搜索树中没有落在区域[!,&]中 的元素。一般情况下,会遇到图 !"# - 中图 !"# -0、图 !"# -1 或图 !"# -, $ 种情形之一。图 !"# -0 和图 !"# -1 导致一个 等价的半无限区域查询。而图 !"# -, 则导致 2 个等价的半 无限区域查询。在所有这些情形下,搜索路径最多为树高 的 2 倍。因此,可以在 $(’ 3 &’( %)平均时间内实现一般的 )*+(, 运算。图 !"# - 中的分叉情况最多出现一次。因此, 在一般情况下,算法的搜索路径如图 !"# . 所示。 !"# -% 456 树 用二叉搜索树来实现字典,可以使字典的各种运算在 $(()时间内完成,其中 ( 为二叉搜索 树的高度。在 % 个结点的随机二叉搜索树中,( 的平均值为 $(&’( %)。但是,某些插入与删除序 列可能产生高度为 !(%)的二叉搜索树。这使得字典支持的各种运算在最坏情况下需要 !(%) 的计算时间。如果能够在每次插入或删除一个元素后,对树的结构进行适当调整,使树的高度 ( 始终保持为 $(&’( %),并且调整树结构的时间也控制在 $(&’( %)时间内,则可以保证在最坏情 况下,字典的各种运算都可以在 $(&’( %)时间内完成。平衡树是实现这一目标的有效工具。按 !"#第 !" 章% 字% % 典 照集合中元素在树结点中的存储方式,可将平衡树分为内结点存储方式和叶结点存储方式 ! 类 不同的平衡树。内结点存储方式的平衡树是将树中所有结点(内结点或叶结点)都用于存储集 合中的元素。而叶结点存储方式的平衡树只用叶结点存储集合中的元素,用内结点存储引导搜 索的键值等信息。"#$ 树是较常用的内结点存储方式的平衡树。 !"# $# !% &’( 树的定义和性质 "#$ 树是满足下面的高度平衡性质的二叉搜索树:它的左子树和右子树都是 "#$ 树,且左、 右子树高度之差的绝对值不超过 %。 "#$ 树具有以下重要性质。 ! 含有 ! 个结点的 "#$ 树的高度为 "(&’( !)。 " 在含有 ! 个结点的 "#$ 树中搜索一个元素需要 "(&’( !)时间。 # 将一个新元素插入一棵 ! 个结点的 "#$ 树中,可得到一棵 ! ) % 个结点的 "#$ 树,且插 入所需的计算时间为 "(&’( !)。 $ 从一棵 ! 个结点的 "#$ 树中删除一个元素,可得到一棵 ! * % 个结点的 "#$ 树,且删除 所需的计算时间为 "(&’( !)。 上述 "#$ 树性质"、#和$以 "#$ 树性质!为基础,在后续讨论中逐一阐明。下面讨论 "#$ 树性质!。 设高度为 # 的 "#$ 树的最少结点数为 $(#)。在这样的一棵 "#$ 树中,其左、右子树的高 度## % %。又由平衡性条件和结点个数最少可知,其左、右子树中一棵的高度为 # % %,另一棵的 高度为 # % !。由此可见 $(#)满足如下递归方程 $(#)& + # & + % # & % $(# % %)’ $(# % !)’ % # ( { % 解此递归方程可得: $(#)& )(# ’ !)% % & % !, % ’ !,( )! #’! % % % !,( )! #’( )! % % 由于 % !, % ’ !,( )! #’! % % % !,( )! #’( )! ( % !, % ’ !,( )! #’! % % 故对任意含有 ! 个结点且高度为 # 的 "#$ 树有 ! " $(#)( % !, % ’ !,( )! #’! % ! 因此 # ’ ! * &’(% ’!, ! (! ’ !)’ &’(% ’!, ! !, # * &’(% ’!, ! (! ’ !)’ &’(% ’!, ! !, % ! # %- ..+. &’((! ’ !)% +- /!0 由此即知,含有 ! 个结点的 "#$ 树的高度为 "(&’( !)。 !"# 数据结构与算法 通常将有 ! 个结点且高度为 "(!"# !)的树称为平衡树。从上面的讨论可知,$%& 树是平衡 二叉搜索树。 在实现时将 $%& 树作为特殊的二叉搜索树。它与前面讨论的二叉搜索树的主要区别是结 点平衡因子以及树的重新平衡运算。在 $%& 树的每个结点处,其平衡因子 #$% 反映在该结点处 左、右子树的平衡状况。若左子树的高度为 &%,右子树的高度为 &’,则 #$% ( &’ ) &%。依 $%& 树的 定义,在 $%& 树的每个结点处均有 ) ’##$%#’。当 #$% ( ( 时,左、右子树的高度相等;当#$% ( ) ’ 时,左子树比右子树高 ’;当 #$% ( ’ 时,右子树比左子树高 ’。在 $%& 树中执行 ’ 次二叉搜索 树的插入运算或删除运算,可能会破坏 $%& 树的高度平衡性质,因此需要重新平衡。这是 $%& 树与二叉搜索树的主要不同之处。下面分别进行讨论。 !"# $# %& 旋转变换 在讨论 $%& 树的重新平衡算法之前,先介绍在二叉搜索树重新平衡算法中常用的工具,结 点的旋转变换。旋转变换的目的是调整结点的子树高度,并维持二叉搜索树性质,即结点中元素 的中序性质。 旋转变换分为单旋转变换和双旋转变换 ) 种类型。 单旋转变换又分为右单旋转变换和左单旋转变换 ) 种类型。单旋转变换可表示为 *"+,+-". (/,0,1-2,3)。1-2 4 ( 对应于右单旋转变换,如图 ’(5 6, 所示;1-2 4 ’ 对应于左单旋转变换,如图 ’(5 67 所示。 图 ’(5 68 单旋转变换 其中右单旋转变换是通过修改结点 * 和 + 的有关指针来实现的。在右单旋转变换中,将结点 + 提升,并将结点 * 降低,就像是绕着结点 * 将整个子树向右进行旋转,从而调整了左、右子树的高 !"#第 ’( 章8 字8 8 典 度。左单旋转变换也是类似的。容易看出单旋转变换保持了二叉搜索树性质。事实上,在图 !"# $ 左边和右边子树中结点的中序列表分别为:(%&’)() 和 %&(’()),它们是完全相同的。 其中 %、’、) 分别表示图中相应子树结点的中序列表。 二叉搜索树的单旋转变换 *+,-,.+/((,&,0.1,2)可实现如下: 3+.0 *+,-,.+/(4,5./6 (,4,5./6 &,./, 0.1,’./-172188 2) { 9 9 4,5./6 1,:; 9 9 1 ";<.50(! $0.1,&); 9 9 : "( $%(-18/,; 9 9 ;<-/=8(0.1,(,1); 9 9 ;<-/=8(! $0.1,&,(); 9 9 ( $%(-18/, "&; 9 9 .>(1)1 $%(-18/, "(; 9 9 .>(!:)2 $%1++, "&; 9 9 85?8 .>(( "": $%58>,): $%58>, "&; 9 9 85?8 : $%1.=<, "&; 9 9 & $%(-18/, ":; } 其中用到函数 ;<.50(0.1,()取结点 ! 在 0.1 方向的儿子结点。函数 ;<-/=8(0.1,(,&)将结点 ! 的 0.1 方向上的儿子结点改为 "。 4,5./6 ;<.50(./, 0.1,4,5./6 () { 9 9 .>(0.1 """)18,@1/ ( $%58>,; 9 9 85?8 18,@1/ ( $%1.=<,; } 3+.0 ;<-/=8(./, 0.1,4,5./6 (,4,5./6 &) { 9 9 .>(0.1 """)( $%58>, "&; 9 9 85?8 ( $%1.=<, "&; } 双旋转变换实际上是 A 次单旋转变换的复合变换。双旋转变换又分为先左后右双旋转变换 和先右后左双旋转变换 A 种类型。双旋转变换可表示为 B+@458*+,-,.+/((,&,1,0.1,2)。0.1 C " 对应于先左后右双旋转变换,如图 !"# D- 所示;0.1 C ! 对应于先右后左双旋转变换,如图 !"# D4 !"# 数据结构与算法 所示。 图 !"# $% 双旋转变换 在先左后右双旋转变换中,先绕着结点 ! 作左单旋转变换,然后再绕着结点 " 作右单旋转变 换。在先右后左双旋转变换中,则先绕着结点 ! 作右单旋转变换,然后再绕着结点 " 作左单旋转 变换。由于单旋转变换保持了二叉搜索树性质,所以由 & 次单旋转变换复合而成的双旋转变换 也保持了二叉搜索树性质。 二叉搜索树的双旋转变换 ’()*+,-(./.0(1(2,3,4,504,6)可实现如下: 7(05 ’()*+,-(./.0(1(*.+018 2,*.+018 3,*.+018 4,01. 504,901/4:64,, 6) { % % *.+018 ;,.,<; % % ; "=>0+5(504,4); % % . "=>0+5(! $504,4); % % < "2 $%2/4,1.; % % =>/1?,(504,2,.); % % =>/1?,(! $504,3,;); % % =>/1?,(504,4,3); % % =>/1?,(! $504,4,2); % % 2 $%2/4,1. "4; !"#第 !" 章% 字% % 典 ! ! " $%#$%&’( "%; ! ! )*(+)+ $%#$%&’( ""; ! ! )*(()( $%#$%&’( "#; ! ! )*(!,)- $%%..( "%; ! ! &/+& )*(# "", $%/&*(), $%/&*( "%; ! ! &/+& , $%%)01( "%; ! ! % $%#$%&’( ",; } 单旋转变换 2.($().’(#,",3)%,-)和双旋转变换 4.56/&2.($().’(#,",%,3)%,-)所需的计算时 间显然为 !(7)。 !"# $# %& ’() 树的插入运算 89: 树与二叉搜索树的插入运算是类似的,惟一的不同之处是,在 89: 树中执行 7 次二叉 搜索树的插入运算,可能会破坏 89: 树的高度平衡性质,因此需要重新平衡。 设新插入的结点为 "。从根结点到结点 " 的路径上,每个结点处插入运算所进入的子树高度 可能增 7。因此在执行 7 次二叉搜索树的插入运算后,需从新插入的结点 " 开始,沿此插入路径 向根结点回溯,修正平衡因子,调整子树高度,恢复被破坏的平衡性质。 新结点 " 的平衡因子为 ;。现考察 " 的父结点 #。若 " 是 # 的左儿子结点,则 6$/(5)应当减 7,否则 6$/(5)应当增 7。根据修正后的 6$/(5)的值分以下 < 种情形讨论。 情形 7:6$/(5)= ;。此时以结点 # 为根的子树平衡,且其高度不变。因此从根结点到结点 # 的路径上各结点子树高度不变,从而各结点的平衡因子不变。此时可结束重新平衡过程。 情形 >:? 6$/(5)? = 7。此时以结点 # 为根的子树满足平衡条件,但其高度增 7。此时将当前 结点向根结点方向上移,继续考察结点 # 的父结点的平衡状态。 情形 <:? 6$/(5)? = >。先讨论 6$/(5)= @ > 的情形。易知,此时结点 " 是结点 # 的左儿子结 点,且 6$/(A)$;。又可分为 > 种情形。 情形 7:6$/(A)= @ 7。此时作 7 次右单旋转变换后,结束重新平衡过程,如图 7;B C 所示。 图 7;B C! 插入重新平衡的情形 7 情形 >:6$/(A)= 7。此时结点 " 的右儿子结点 $ 非空。根据 6$/(,)的值,又分为 6$/(,)= ;、 6$/(,)= @ 7 和 6$/(,)= 7 的 < 种情形。在这 < 种情形下,分别作 7 次双旋转变换后,结束重新 !!" 数据结构与算法 平衡过程,如图 !"# $%、图 !"# $& 和图 !"# $’ 所示。 图 !"# $( 插入重新平衡的情形 ) &%*(+), ) 的情形与 &%*(+), - ) 的情形是对称的。 实现上述插入一个结点后的重新平衡算法 ./012341&%*(5,6)描述如下: 5789 ./012341&%*(&3*8/: 5,;8/%2<6211 6) { ( ( !!( ( ( ( ( +( ( ( ( !! ( ( !! ) !! ( ( !! 5 !! ( ( !! !’ !! ( ( !! = 4 !! !"#第 !" 章( 字( ( 典 ! ! "#$ %,&,&"’; ! ! %$("#) *,+; ! ! ,-"(.( !(/ $%’00$ ""1)){ ! ! ! * "1 $%23’.#$; ! ! ! &"’ "(1 ""* $%(.4$)?5 :6; ! ! ! % "* $%%3(; ! ! ! "4(&"’ ""5)% $$;! ! ! ! ! ! ! !!在 * 的左子树中插入 !! ! ! ! .(7. % **; !!在 * 的右子树中插入 !! ! ! ! * $%%3( "%; ! ! ! "4(% ""5)%’.3); !!情形 6 !! ! ! ! "4(% !"6 88 % !" $6){ 9 !情形 ::; %3((*); "< !! ! ! ! ! & "(% &5)? $6 : *6; ! ! ! ! "4(1 $%%3( ""&){ !!情形 6 !! ! ! ! ! ! =0$3$"0#(*,1,&"’,/); ! ! ! ! ! * $%%3( "5; ! ! ! ! ! 1 $%%3( "5; ! ! ! ! } ! ! ! ! .(7.{!!情形 < !! ! ! ! ! ! + ">-"(&(6 $&"’,1); ! ! ! ! ! ?0*%(.=0$3$"0#(*,1,+,&"’,/); ! ! ! ! ! "4(+ $%%3( ""&)* $%%3( " $&; ! ! ! ! ! .(7. * $%%3( "5; ! ! ! ! ! "4(+ $%%3( "" $&)1 $%%3( "&; ! ! ! ! ! .(7. 1 $%%3( "5; ! ! ! ! ! + $%%3( "5; ! ! ! ! } ! ! ! ! %’.3); ! ! ! } ! ! ! 1 "*;! ! ! ! !!情形 <:; %3((*); "6 !! ! ! } } 上述重新平衡算法遇情形 6 时,立即结束重新平衡过程;遇情形 : 时,作 6 次单旋转变换或 双旋转变换后,结束重新平衡过程;遇情形 < 时,沿插入路径上移。在每个结点处耗费 !(6)计算 时间。@AB 树中有 " 个结点时高度为 !((0C ")。因此在最坏情况下,算法 D#7.’$=.%3((1,/)需 要 !((0C ")计算时间。进而在 @AB 树中插入一个元素需要 !((0C ")计算时间。 !"# 数据结构与算法 !"# $# $% &’( 树的删除运算 !"# 树与二叉搜索树的删除运算是类似的。惟一的不同之处是,在 !"# 树中执行 $ 次二叉 搜索树的删除运算,可能会破坏 !"# 树的高度平衡性质,因此需要重新平衡。 设被删除结点为 !,其惟一的儿子结点为 "。结点 ! 被删除后,结点 " 取代了它的位置。从根 结点到结点 " 的路径上,每个结点处删除运算所进入的子树高度可能减 $。因此在执行 $ 次二叉 搜索树的删除运算后,需从结点 " 开始,沿此删除路径向根结点回溯,修正平衡因子,调整子树高 度,恢复被破坏的平衡性质。 考察 " 的父结点 #。若 " 是 # 的左儿子结点,则 %&’(()应当增 $,否则 %&’(()应当减 $。根据 修正后的 %&’(()的值分以下 ) 种情形讨论。 情形 $:* %&’(()* + $。此时以结点 # 为根的子树满足平衡条件,且其高度不变。因此从根 结点到结点 # 的路径上各结点子树高度不变,从而各结点的平衡因子不变。此时可结束重新平 衡过程。 情形 ,:%&’(()+ -。此时以结点 # 为根的子树平衡,但其高度减 $。此时将当前结点向根结 点方向上移,继续考察结点 # 的父结点的平衡状态。 情形 ):* %&’(()* + ,。先讨论 %&’(()+ . , 的情形。易知,此时结点 " 是结点 # 的右儿子结 点。考察结点 # 的左儿子结点 $,根据 %&’(/)的值又可分为 , 种情形。 情形 $:%&’( /)$$。此时作 $ 次右单旋转变换后,使结点 # 恢复平衡。%&’( /)+ - 和 %&’(/)+ . $的情形分别如图 $-0 $-& 和图 $-0 $-% 所示。 图 $-0 $-1 删除重新平衡的情形 $ 当 %&’(/)+ - 时,经上述旋转变换后,以结点 $ 为根的子树满足平衡条件,且其高度与变换 前结点 # 的高度相同。因此从根结点到结点 $ 的路径上各结点子树高度不变,从而各结点的平 !"!第 $- 章1 字1 1 典 衡因子不变。此时可结束重新平衡过程。 当 !"#($)% & ’ 时,经上述旋转变换后,结点 ! 为根的子树高度比变换前结点 " 的高度少 ’,因此要继续考察 ! 的父结点的平衡状态,即将当前考察结点向根结点方向上移。 情形 (:!"#($)% ’。此时结点 ! 的右儿子结点 # 非空。根据 !"#())的值,又分为 !"#())% *、!"#())% & ’ 和 !"#())% ’ 的 + 种情形。在这 + 种情形下,分别作 ’ 次双旋转变换后,使结点 " 恢复平衡,如图 ’*, ’’"、图 ’*, ’’! 和图 ’*, ’’- 所示。 图 ’*, ’’. 删除重新平衡的情形 ( 经上述旋转变换后,结点 # 为根的子树高度比变换前结点 " 的高度少 ’,因此要继续考察 # 的父结点的平衡状态,即将当前考察结点向根结点方向上移。 !"#(/)% ( 的情形与 !"#(/)% & ( 的情形是对称的。 实现上述删除一个结点后的重新平衡算法 01#12131!"#(4,5,6)描述如下: !"# 数据结构与算法 !"#$ %&’&(&)&*+’(*(’#,- !,*(’#,- .,/#,+0120&& 2) { 3 3 !!删除一个结点后的重新平衡算法 !! 3 3 !!初始状态:被删除结点为 .,! 是其惟一的儿子结点,且 ! !"0""( !! 3 3 #,( *,$,$#0; 3 3 *(’#,- 4,5; 3 3 67#’&( !(2 $%0""( ""!)){ 3 3 3 4 "(!)?! $%.+0&,( :. $%.+0&,(; 3 3 3 $#0 "(! ""4 $%’&8()?9 ::; 3 3 3 #8(!!)$#0 "(. "". $%’&8()?9 ::; 3 3 3 * "4 $%*+’; 3 3 3 #8($#0 ""9)* **;!!在 4 的左子树中删除 !! 3 3 3 &’;& * $$; !!在 4 的右子树中删除 !! 3 3 3 4 $%*+’ "*; 3 3 3 #8(* "": )) * "" $:)*0&+-;!!情形 : !! 3 3 3 #8(* !"9){ !!情形 <:= *+’(4)= "> !! 3 3 3 3 $ "(* &9)? $: : *:; 3 3 3 3 ! "?7#’$(: $$#0,4); 3 3 3 3 #8($ !! $%*+’ %"9){!!情形 ::*+’(!)$: !! 3 3 3 3 3 )"(+(#",(4,!,: $$#0,2); 3 3 3 3 3 #8(! $%*+’ ""9){4 $%*+’ "$;! $%*+’ "$$;*0&+-;}!!情形:(+):*+’(!)"9 !! 3 3 3 3 3 &’;& {4 $%*+’ "9;! $%*+’ "9;}!!情形 :(*):*+’(!)" $: !! 3 3 3 3 3 } 3 3 3 3 &’;&{!!情形 >:*+’(!)": !! 3 3 3 3 3 5 "?7#’$($#0,!); 3 3 3 3 3 %"4*’&)"(+(#",(4,!,5,: $$#0,2); 3 3 3 3 3 #8(5 $%*+’ ""$)4 $%*+’ " $$; 3 3 3 3 3 &’;& 4 $%*+’ "9; 3 3 3 3 3 #8(5 $%*+’ "" $$)! $%*+’ "$; 3 3 3 3 3 &’;& ! $%*+’ "9; 3 3 3 3 3 5 $%*+’ "9; 3 3 3 3 3 ! "5; 3 3 3 3 } 3 3 3 } 3 3 3 &’;& ! "4; 3 3 } } !"#第 :9 章3 字3 3 典 上述删除一个结点后的重新平衡算法遇情形 ! 时,立即结束重新平衡过程;遇情形 " 时,沿 删除路径上移;遇情形 # 时,作 ! 次单旋转变换或双旋转变换后,沿删除路径上移。在每个结点 处耗费 !(!)计算时间。$%& 树中有 " 个结点时高度为 !(’() ")。因此在最坏情况下,算法 *+’+,+-+./’(0,1,2)需要 !(’() ")计算时间。进而在 $%& 树中删除一个元素需要 !(’() ")计算 时间。 !34 56 应6 6 用 条形图问题:给定 " 个数据,条形图问题要求绘出表示这 " 个数据的条形统计图,即统计出 这 " 个数据中有多少个不同的值,每个值出现的频率是多少。图 !34 !" 是对给定的 !3 个非负整 数绘制条形图的例子。其中图 !34 !"/ 是输入数据;图 !34 !". 是频率统计;图 !34 !"7 是相应的 条形统计图。 图 !34 !"6 条形统计图 条形图常用于表示数据分布情况。例如学生考试成绩分布,居民收入分布情况以及图像灰 度等级分布等。当给定的 " 个数据不是非负整数时,通常可以通过映射将它们转换为非负整数。 例如,可以将 "8 个英文字母{/,.,7,⋯ ,9}映射为{3,!,⋯ ,"5}。当输入数据转换为非负整数 后,数值范围在 3 : # 之间,且 # 的值相对较小时,可以设计一个很简单的算法,在 !(")时间内完 成输入数据的频率统计。 0(;< =/;>() { 6 6 ;>, ;,?+@,>,A,!B; 6 6 1A;>,C(#D>,+A >E=.+A (C +’+=+>,F />< A/>)+ ’>#); !"# 数据结构与算法 ! ! "#$%&(#’ ( ’ (#,)%,)*); ! ! !!创建条形图数组 + !! ! ! + ",$--.#((* */)!"012.&(0%3)); ! ! &.*(0 "4;0 &"*;0 **)+[0]"4; ! ! !!输入数据并统计 !! ! ! &.*(0 "/;0 &"%;0 **){ ! ! ! 5*0%3&(#6%32* 2-2,2%3 ’ (’%#,0); ! ! ! "#$%&(#’ (#,)728); ! ! ! +[728]**; ! ! ! } ! ! !!输出条形图 !! ! ! 5*0%3&(#90"30%#3 2-2,2%3" $%( &*2:;2%#02" $*2’%#); ! ! &.*(0 "4;0 &"*;0 **) ! ! ! 0&(+[0])5*0%3&(#’ ( ’ ( ’%#,0,+[0]); } 上面的算法中用 +[0]来记录值 ! 的频率。对输入数据的一次线性扫描即可确定 +[0]的值。 用第 < 章中介绍的统计字符串频率的散列表方法也可以达到同样的效果。 当输入数据范围 " 的值很大或输入数据不便于转换为非负整数,甚至没有合适的散列函数 (例如输入数据为实数)时,上面讨论的线性扫描算法和散列表方法就都不适用了。此时可以先 对输入数据排序,然后对排好序的数据作一次线性扫描,确定各数据的出现频率。在最坏情况 下,排序需要 #($ -.= $)时间,其后的线性扫描需要 #($)时间。因此算法所需要的计算时间为 #($ -.= $)。如果输入数据中值不相同的数据个数 % 较小时,用本章介绍的抽象数据类型字典, 可以将计算时间进一步减少为 #($ -.= %)。算法的主要思想是用 >?@%"2*3A0"03(2,B((,C)将输 入数据 & 存入字典 ’ 中。仅当数据 & 不在字典 ’ 中时才插入数据 &。当数据 & 已在字典 ’ 中时, 用函数 B(( 将其统计值增加 /。所有数据都插入字典 ’ 后完成输入数据的频率统计。 D.0( B(((2C852 2){2 $%#.;%3 **;}!!#.;%3 值增加 / 函数 !! D.0( ,$0%() { ! ! 0%3 0,%; ! ! >0%$*8C*22 C ">0%$*8@%03(); ! ! 5*0%3&(#6%32* %;,E2* .& 2-2,2%3"’%#); ! ! "#$%&(#’ (#,)%); ! ! !!输入数据到二叉搜索树 C 中 !! ! ! &.*(0 "/;0 &"%;0 **){ ! ! ! 2C852 2 "F2G6-2,();!!输入数据 !! !"#第 /4 章! 字! ! 典 ! ! ! "#$%&’(#(%&)# )*)+)%&’% #); ! ! ! ,-.%’(#/ 0#,1) $%2)3); ! ! ! ) $%-45%& "6; ! ! ! !!将 ) 插入树 7 中 !! ! ! ! !!如果 7 中已有该数据则相应元素 -45%& 值增加 6 !! ! ! ! 89:%,)#&;$,$&(),<00,7); ! ! ! :%=5&(7); ! ! ! } ! ! !!按照树 7 的中序列表输出统计结果 !! ! ! "#$%&’(#>$,&$%-& )*)+)%&, .%0 ’#)?5)%-$), .#)’%#); ! ! :%=5&(7); } 用 <;@ 树实现字典可以保证树 ! 的高度为 "( *4A #),因此上述算法所需的计算时间 为"($ *4A #)。 本 章 小 结 本章讲授的主题是以有序集为基础的抽象数据类型字典及其实现方法。字典支持的主要集 合运算是成员运算、插入运算和删除运算。用数组实现字典时,利用线性序将字典中的元素从小 到大依序存储在数组中。数组下标的序关系用来反映字典元素之间的序关系,从而有效地实现 与线性序有关的一些运算。在这种表示法下,可用二分查找算法高效地实现成员运算。用数组 实现字典的缺陷是插入和删除运算的效率较低。用二叉搜索树实现字典结合了数组和链表二者 的优点,使得字典运算的效率较高。在平均情况下,含有 $ 个结点的二叉搜索树的成员运算、插 入运算和删除运算所需时间均为 "(*4A $)。在最坏情况下,二叉搜索树可能退化成为一条链。 这使得字典支持的各运算在最坏情况下需要 !($)的计算时间。如果能够在每次插入或删除一 个元素后,对树的结构进行适当调整,使树的高度始终保持为 "(*4A $),并且调整树结构的时间 也控制在 "(*4A $)时间内,则可以保证在最坏情况下,字典的各种运算都可以在 "(*4A $)时间 内完成。<;@ 树正是实现这个目标的有效工具。本章较详细地介绍了平衡二叉搜索树的结点 旋转变换及实现方法。以结点旋转变换为主要手段,讨论了 <;@ 树的插入重新平衡运算和删除 重新平衡运算及实现方法,从而保证了字典支持的各种运算都可以在 "(*4A $)时间内完成。 习! ! 题 !"# !$ 对于 B 个元素 6,C,D,B,画出表示这 B 个元素组成的集合的所有的二叉搜索树。 !"# %$ 用函数 :%,)#& 将整数 E,C,F,G,H,I,J,6 插入到一个空的二叉搜索树中去。 !"# &$ 用 >)*)&) 从二叉搜索树中连续删除 C 个元素时,所得到的二叉搜索树与删除顺序有关系吗? !"# ’$ 假设在一棵二叉搜索树中已存有 6 K 6 GGG 之间的一些数。现要找出数 DID,下列的结点序列中哪一 !"# 数据结构与算法 个不可能是所检查的序列? (!)","#",$%!,&’(,&&%,&$$,&’),&*& (")’"$,""%,’!!,"$$,(’(,"#(,&*",&*& (&)’"#,"%",’!!,"$%,’!","$#,&*& ($)",&’’,&(),"!’,"**,&(",&(!,")(,&*& (#)’&#,")(,&$),*"!,"’’,&’",&#(,&*& !"# $% 在一棵表示有序集 ! 的二叉搜索树中,任意一条从根到叶的路径将 ! 分为 & 部分:在该路径左边结 点中的元素组成的集合 !!,在该路径上的结点中元素组成的集合 !",以及该路径右边结点中元素组成的集合 !&。显然有 ! " !!%!"%!&。对任意 #&!!,$&!",%&!& 是否总有 ##$#%,为什么? !"# &% 试写出实现二叉搜索树中运算 +,-.-/-001,(2)和 34//-001,(2)的算法。 !"# ’% 设 & 为表示有序集 ! 的一棵二叉搜索树。! 中的元素 ’ 存储于 & 的一个叶结点中,其父结点中存储 的元素为 (,试证明 ( 是 ! 中大于 ’ 的最小元素或 ( 是 ! 中小于 ’ 的最大元素。 !"# (% 试证明二叉搜索树中任意有 " 个儿子的结点,其后继没有左儿子,其前驱没有右儿子。 !"# )% 如果二叉搜索树结点中不设指向父结点的指针,如何实现算法 560-,7 和 8-9-7-? !"# !"% 假设要对 ) 个数进行排序,可以反复用 560-,7 运算将这 ) 个数插入到一棵初始为空的二叉搜索树 中,然后按二叉搜索树结点的中序列表输出这 ) 个数。试说明这个算法的正确性,并分析这个算法在最好和最 坏情况下所需的计算时间。 !"# !!% 采用线索二叉搜索树可使 :;< 树的许多实现细节得到简化。试讨论如何在 :;< 树中使用线索。 !"# !*% 试证明在 :;< 树的各算法中只用到单旋转变换就足够了。设计 :;< 树的只用单旋转变换的插入 和删除元素后的重新平衡算法。 !"# !+% 设 * 是二叉树 & 的一个结点,+(*)和 ,(*)分别是结点 * 到 & 的叶结点的所有路径中最短路径和最 长路径的长度。显然 ,(*)是结点 * 在树 & 中的高度。如果对于 & 的任意结点 * 均有 ,(*)#"+(*),则称树 & 是 半平衡的。 (!)设 & 是一棵有 ) 个结点且高度为 , 的半平衡二叉树,试证明 ,#" 91=() - ")- "。 提示:对高度 , 用数学归纳算法证明 ) " ",. " -! / " 0 , 偶 & 1 "(,/!). " / " 0 , { 奇 (")试说明如何实现半平衡二叉搜索树。在执行 ! 次插入或删除运算后,如何恢复其半平衡性? !"#第 !% 章> 字> > 典 书书书 第 !! 章" 优 先 队 列 学习目标 ! 理解以集合为基础的抽象数据类型优先队列。 ! 理解用字典实现优先队列的方法。 ! 理解优先级树和堆的概念。 ! 掌握用数组实现堆的方法。 ! 理解以集合为基础的抽象数据类型可并优先队列。 ! 理解左偏树的定义和概念。 ! 掌握用左偏树实现可并优先队列的方法。 ! 掌握堆排序算法。 !!" !# 优先队列的定义 优先队列也是一个以集合为基础的抽象数据类型。优先队列中的每一个元素都有一个优先 级。优先队列中元素 ! 的优先级记为 "(!),它可以是一个实数,也可以是一个一般的全序集中 的元素。定义在优先队列上的基本运算有: ! $%&(’):返回优先队列 # 中具有最小优先级的元素。 " (&)*+,(-,’):将元素 ! 插入优先队列 #。 # .*/*,*$%&(’):删除并返回优先队列 # 中具有最小优先级的元素。 优先队列这个词可以作如下解释:“队列”说明人或事物在排队等待某种服务。如果服务是 按照排队顺序进行的,即先到者先得到服务,则这种队列就是通常的队列。在优先队列中,“优 先”说明服务并不是按排队顺序进行的,而是按照每个对象的优先级顺序进行的。分时系统是 应用优先队列的一个例子,当有一批作业在等待分时系统处理时,每个作业都有一个优先级。一 般情况下,希望将耗时少的作业尽快处理完,也就是说,短作业将优先于那些已经消耗了一定时 间的作业。使短作业优先而又不锁死长作业的办法之一是为每个作业 " 分配一个优先级 !"""#$%&(’)()*+,(’)。其中 #$%&(’)表示到现在为止,作业 ! 所消耗的时间总量,)*+,(’)表示 从某个零时刻算起,作业 ! 初次到达的时间。!"" 是一个可以根据需要进行选择的数,通常选择 为大于作业数目的一个数。为了给作业安排时间,分时系统中设置了优先队列 -.)/)01,由函 数 )*+,+23 和 4%3%5, 对这个优先队列进行处理。每当一个新作业到达时,函数 )*+,+23 就将这个作业 的记录插入到优先队列 -.)/)01 中。当系统有一段时间可供使用时,函数 4%3%5, 就从优先队列 -.)/)01 中选出一个作业,并将该作业从 -.)/)01 中删去,由 4%3%5, 暂存该作业记录,在该作 业用完分配给它的时间后,带一个新的优先级重新入队。 !!6 78 用字典实现优先队列 由于优先队列与字典的相似性,所有实现字典的方法都可用于实现优先队列。优先队列中 元素的优先级可以看作是字典中元素的线性序值。但它们之间还是有一些细微的差别。在字典 中,不同的元素具有不同的线性序值。因此字典的插入运算仅当要插入元素 " 的线性序值与当 前字典中所有元素的线性序值都不同时才执行插入。对于优先队列来说,不同的元素可以有相 同的优先级。因此,优先队列的插入运算即使在当前优先队列中存在与要插入元素 " 有相同的 优先级的元素时,也要执行元素 " 的插入。 如果用有序链表实现优先队列,可在 #(!)时间内实现 9+*(:)和 ;%3%,%9+*(:)运算,但 )*( $%<,(=,:)运算在最坏情况下需要 #( $)时间,其中 $ 为插入元素时,优先队列中已有的元素 个数。 如果用二叉搜索树来表示有 $ 个元素的优先队列,则在最坏情况下 )*$%<,(=,:)和 ;%3%,%( 9+*(:)运算需要 #($)时间,在平均情况下需要 #(3>?$)时间。如果用 .@A 树来代替二叉搜索 树,则在最坏情况下可用 #(3>?$)时间实现 )*$%<,(=,:)和 ;%3%,%9+*(:)运算。 如果用无序链表实现优先队列,则可在 #(!)时间内实现 )*$%<,( =,:)运算,但 ;%3%,%9+* (:)运算却需要 #($)时间。 !!6 B8 优先级树和堆 用二叉搜索树实现优先队列,实际上用到的仍是二叉搜索树性质,即对二叉搜索树的结点进 行中序列表时,得到的是优先队列中所有元素按其优先级从小到大的排列。然而,这种性质对于 优先队列来说不是必要的。因此,对二叉搜索树作适当修改,将二叉搜索性质换成下面的优先性 质,从而引入优先级树。 优先级树是满足下面的优先性质的二叉树: ! 树中每一结点存储一个元素。 " 任一结点中存储的元素的优先级不大于其儿子结点中存储的元素的优先级。 显而易见,优先级树的根结点中存储的元素具有最小优先级。从根到叶的任一条路径上,各 结点中元素按优先级的非增序排列。 !!"第 !! 章8 优 先 队 列 图 !!" !# 极小化堆 按照上述优先级树的定义,在一棵优先级树的任意 一条从根到叶的路径上,较高层结点有较小优先级,这 类优先级树称为极小化优先级树。如果在一棵优先级 树中,任一结点中存储的元素的优先级不小于其儿子结 点中存储的元素的优先级,则相应的优先级树称为极大 化优先级树。 从优先级树的定义可以看出,表示同一优先队列的 优先级树不是惟一的。与二叉搜索树一样,优先级树可 能退化成一个线性表。由于在极小化优先级树中执行 $%&’()(*,+)和 ,’-’)’./%(+)运算所需的 时间与树高有关,所以希望用平衡的优先级树来表示优先队列。当一棵优先级树是近似满二叉 树时,称其为堆或偏序树。极小化优先级树所相应的堆称为极小化堆;极大化优先级树所相应的 堆称为极大化堆。图 !!" ! 中的优先级树是一个极小化堆。在下面的讨论中,如果不特别指明, 所说的堆即指极小化堆,优先级树即指极小化优先级树。 用堆来实现优先队列可以获得较高的效率,执行 $%&’()(*,+)和 ,’-’)’./%(+)运算都只要 !(-01")时间。 在堆上执行 ,’-’)’./%(+)运算时,不是简单地把树根删去,并取出其中存放的具有最小优 先级的元素,而是删去堆中最底层最右边的叶结点,并用其中所存放的元素取代树根中应被删除 的元素。由于这样做可能会破坏堆的优先性质,因此还要将这个元素不断地与它的具有较小优 先级的儿子交换位置,直到它的 2 个儿子的优先级都不小于它的优先级或它已降到叶结点的位 置为止。例如,在图 !!" ! 的堆中删除最小元的过程如图 !!" 2 所示。 图 !!" 2# 从堆中删除最小元素 按上述办法在一个具有 " 个元素的优先队列上执行 ,’-’)’./%(+)运算,只用 !(-01")时间。 因为从树根到树叶的任一条路径上最多只有 ! 3 -01" 个结点,而元素每下降一层只花费!(!) 时间。 下面讨论如何在堆上执行 $%&’()(*,+)运算。首先,将存放新元素的结点添加在堆的最底 层,使它仍为一近似满二叉树。例如,要在图 !!" 24 所示堆中插入一个优先级为 5 的元素时,先 将存储该元素的结点添加在堆的最底层上得到图 !!" 67。这样做仍然可能破坏堆的优先性质。 为了保持堆的优先性质,只要新元素的优先级小于其父结点中元素的优先级,就交换它们的位置 (如图 !!" 68 和图 !!" 64 所示),直到新元素的优先级不小于其父结点中元素的优先级或已升到 根结点时为止,这时得到的近似满二叉树就是一个堆了(如图 !!" 69 所示)。 往堆中插入一个元素所需的时间正比于新元素沿树上升时经过的结点数目,这个数不超过 !!" 数据结构与算法 图 !!" #$ 往堆中插入一个元素 ! % &’(!,所以 )*+,-.(/,0)运算在最坏情况下也只用 "(&’(!)时间。 !!" 1$ 用数组实现堆 由于堆具有一些特殊的性质,所以可以用特殊的方法来实现。当堆中有 ! 个元素时,可以将 这些元素存放在数组 # 的前 ! 个单元里,其中堆的根结点中元素存放在 2[!]中。一般地,2[3] 的左儿子结点中的元素(如果存在)存放在 2[4 !3]中;2[3]的右儿子结点中的元素(如果存在) 存放在 2[4 !3 % !]中。换句话说,当 $ 5 ! 时,2[3]的父结点中的元素存放在 2[36 4]中。直观地 看,元素 2[!],2[4],⋯ ,2[*]是堆中元素按层序列表,即从根结点开始逐层往下,每层中从左 到右地将结点中元素列出。例如,图 !!" ! 的堆中元素在数组中的存储顺序为:!7,48,!8,17,48, 47,#7,87。 用数组实现的极小化堆 93*:,;< 的结构定义如下: .=<,>,? +.-@A. B3*:,;< !0,;<; .=<,>,? +.-@A. B3*:,;< { 3*. &;+.,B;/+3C,; D,.).,B !:,;<;!!元素数组 !! }93*:,;<; 其中 :,;< 是堆数组,用于存储堆中元素。B;/+3C, 是堆数组的最大长度。&;+. 是一个整数,它指 !"#第 !! 章$ 优 先 队 列 示堆数组被优先队列中元素占用的最后一个单元的位置。 函数 !"#$%&’(#")($%&’*"+%)创建一个空堆。其中堆数组的最大长度为 $%&’*"+%。 $%&’ !"#$%&’(#")("#) $%&’*"+%) { $%&’ $ ",&--./(0"+%.1 !$); $ #$,&20"+% "$%&’*"+%; $ #$3%&’ ",&--./(($ #$,&20"+% %4)!0"+%.1(*%)()%,)); $ #$-&0) "5; 6%)76# $; } 在堆 ! 中插入一个元素 " 的算法 $%&’(#0%6)(2,$)实现如下: 8."9 $%&’(#0%6)(*%)()%, 2,$%&’ $) {!!堆插入运算!! "#) "; "1($ #$-&0) ""$ #$,&20"+%):66.6(&$%&’ "0 17--&);!!堆已满!! !!从堆底开始搜索元素 2 的插入位置 !! " " %%$ #$-&0); ;3"-%(" !"4 << -%00(2,$ #$3%&’["!=])){ $ #$3%&’["]"$ #$3%&’["!=];> !!元素下移!! "! "=;> > > > > > > > > > > >!!向上搜索!! } $ #$3%&’["]"2; } 在堆 ! 中抽取最小元的算法 ?%-%)%!"#($)描述如下: *%)()%, ?%-%)%!"#($%&’ $) {> !!抽取堆中最小元!! "#) ",/"; *%)()%, 2,@; "1($ #$-&0) ""5):66.6(&$%&’ "0 %,’)@&);> !!堆已空!! 2 "$ #$3%&’[4];> !!堆中最小元!! !!重构堆!! @ "$ #$3%&’[$ #$-&0) ##];> !!堆中最后一个元素 !! !"! 数据结构与算法 !!从堆顶开始搜索元素 ! 的位置 !! " "#,$ $ !!堆的当前位置 !! %" "&;$ !!" 的儿子结点在堆中位置 !! ’(")*(%" ’"+ #$),-.){!!搜索 ! 的插入位置 !! "/(%" ’+ #$),-. 00 )*--(+ #$(*,1[%" %#],+ #$(*,1[%"]))%" %%; "/(!)*--(+ #$(*,1[%"],!))23*,4; + #$(*,1["]"+ #$(*,1[%"];!!儿子结点上升 !! " "%";!!当前结点下降 !! %" ! "&; } + #$(*,1["]"!; 3*.536 7; } 给定一个有 ! 个元素的数组 ",可用下面的算法 85")9+*,1(,,6,,33,!-":*)在 #(!)时间内将 数组 " 调整为一个堆。实现方法如下: +*,1 85")9+*,1(;*.<.*= ,[],"6. -":*,"6. ,33,!-":*) {$ !!将数组 , 调整为一个堆 !! "6. %,"; ;*.<.*= !; +*,1 + ">"6+*,1<6".(,33,!-":*); + #$(*,1 ",; + #$),-. "-":*; + #$=,7-":* ",33,!-":*; /?3(" "+ #$),-.!&;" @ "#;" ##){ ! "+ #$(*,1["]; !!调整 ! 的位置 !! % "&!";!!结点 " 的左儿子结点 !! ’(")*(% ’"+ #$),-.){ "/(% ’+ #$),-. 00 )*--(+ #$(*,1[% %#],+ #$(*,1[%]))% %%; "/(!)*--(+ #$(*,1[%],!))23*,4; + #$(*,1[%!&]"+ #$(*,1[%];!!(*,1[%]上移 !! % ! "&;$ $ $ $ $ $ $ $ $ $ $ $ !!% 下降 # 层 !! } + #$(*,1[%!&]"!; } 3*.536 +; !"#第 ## 章$ 优 先 队 列 } 上述算法的 !"#$% 循环耗时 !("# ),其中 "# 是以结点 # 为根的子树的高度。由于近似满二叉 树 &[’:(]的高度为 " $ %&$)*(’ ( ’) ,第 ) 层结点个数至多为 +) * ’ ,因此至多有 +) * ’ 个结点的高度 为 ") $ " * ) ( ’。从而算法的 !"#$% 循环共耗时 ! # "*’ ) $ ’ +)*’(" * ) ( ’( )) $ ! # "*’ + $ ’ ++( )"*+ $ ! +" # "*’ + $ ’ +!+( )+ $ !(+" )$ !(’) , , 由此即知,算法 -.#$/0%&1 所需的计算时间是 !(’)。 利用上述堆结构进行排序的算法称为堆排序算法,可在 !(’$)*’)时间内实现对给定数组 , 的就地排序。 2)#/ 0%&13)45(3%565%7 &[],#(5 () {, !!堆排序算法 !! !!建初始堆 !! #(5 #; 3%565%7 8; 0%&1 0 "-.#$/0%&1(&,(,(); !!从堆中逐次抽取最小元 !! 9)4(# "( #’;# $"’;# ##){ 8 ":%$%5%;#((0); &[# %’]"8; } } ’’< =, 可并优先队列 可并优先队列也是一个以集合为基础的抽象数据类型。除了必须支持优先队列的 6(>%45 和 :%$%5%;#( 运算外,可并优先队列还支持 + 个不同优先队列的合并运算 ?)(@&5%(&5%。 用堆来实现优先队列,可在 !($)*’)时间内支持同一优先队列中的基本运算。但合并 + 个 不同优先队列的效率不高。下面讨论的左偏树结构不但能在 !($)*’)时间内支持同一优先队列 中的基本运算,而且还能有效地支持 + 个不同优先队列的合并运算 ?)(@&5%(&5%。 !!" #" !$ 左偏树的定义 左偏树是一类特殊的优先级树。与优先级树类似,左偏树也有极小化左偏树与极大化左偏 树之分。为了确定起见,下面所讨论的左偏树均为极小化左偏树。常用的左偏树有左偏高树和 !"# 数据结构与算法 左偏重树 ! 种不同类型。顾名思义,左偏高树的左子树偏高,而左偏重树的左子树偏重。下面给 出其严格定义。 若将二叉树结点中的空指针看作是指向一个空结点,则称这类空结点为二叉树的前端结点。 并规定所有前端结点的高度(重量)为 "。 对于二叉树中任意一个结点 !,递归地定义其高度 "(!)为 # # "(!) # $%& {"($),"(%)} & ’ 其中,$ 和 % 分别是结点 ! 的左儿子结点和右儿子结点。 一棵优先级树是一棵左偏高树,当且仅当在该树的每个内结点处,其左儿子结点的高(" 值) 大于或等于其右儿子结点的高(" 值)。 对于二叉树中任意一个结点 !,其重量 ’(!)递归地定义为 # # ’(!) # ’($) & ’(%) & ’ 其中,$ 和 % 分别是结点 ! 的左儿子结点和右儿子结点。 一棵优先级树是一棵左偏重树,当且仅当在该树的每个内结点处,其左儿子结点的重( ’ 值)大于或等于其右儿子结点的重(’ 值)。 左偏高树具有下面性质。 设 ! 是一棵左偏高树的任意一个内结点,则 ! 以 ! 为根的子树中至少有 !"(!) ( ’ 个结点。 " 如果以 ! 为根的子树中有 ) 个结点,则 "(!)的值不超过 ()*() & ’)。 # 从 ! 出发的最右路径的长度恰为 "(!)。 证明:! 设结点 ! 位于树的第 * 层。由 "(!)的定义知,以 ! 为根的子树在第 * & + 层的每个 结点恰有 ! 个儿子结点,"$ + $ "(!)( ’。因此,以 ! 为根的子树在第 * & + 层恰有 !+ 个结点, "$+ $ "(!)( ’。从而,以 ! 为根的子树中至少有 # "(!)(’ + # " !+ # !"(!) ( ’ 个结点。 " 由!可立即推出。 # 由 "(!)的定义,以及在左偏高树中每个内结点处,其左儿子结点的 " 值大于或等于其右 儿子结点的 " 值,即可推出。 !!" #" $% 用左偏树实现可并优先队列 左偏树的结点类型 +,-./)01 说明如下: 2341015 627892 :;(2&)01 !(2(%&<; 2341015 627892 :;(2&)01 { %&2 6;!!结点高度 !! =12>21$ 1(1$1&2; (2(%&< (152,7%*:2; }+,-./)01; !"#第 ’’ 章# 优 先 队 列 !"!#$% &’()*+,$-.’(/’"0"’1 2,#$" 3) { !"!#$% 4; #5((4 "16!!-7(3#8’-5()*+,&-.’))) ""9):;;-;(&:2<6=3"’. 1’1-;>? &); 4 #$’!’1’$" "2; 4 #$3 "3; 4 #$!’5" "9; 4 #$;#@<" "9; ;’"=;$ 4; } 其中,’!’1’$" 存放优先队列中的元素;! 保存当前结点的高度值;!’5" 和 ;#@<" 分别是指向左、右儿 子结点的指针。 函数 &’()*+,$-.’(2,3)创建一个存储元素 " 且结点高度值为 ! 的新结点。 在此基础上,用左偏树实现的可并优先队列 A#$)*+, 描述如下: ">4’.’5 3";=7" 4’.’5 3";=7" )。它将 D 棵分别以 " 和 # 为根的左偏树合并为 E 棵新的以 " 为根的左偏树。 !"!#$% C-$76"’$6"’(!"!#$% 2,!"!#$% >) { #5(!>);’"=;$ 2;!!> 是 E 棵空树 !! #5(!2);’"=;$ >;!!2 是 E 棵空树 !! !"# 数据结构与算法 !!! 和 " 均非空 !! #$(%&’’(" #$&%&(&)*,! #$&%&(&)*))’+,-(!,"); !!现在 ! #$&%&(&)* $"" #$&%&(&)* !! ! #$.#/0* "12)3,*&),*&(! #$.#/0*,"); #$(!! #$%&$*){!!! 的左子树为空树 !! !!交换其左、右子树 !! ! #$%&$* "! #$.#/0*; ! #$.#/0* "4; ! #$’ "5;} &%’& {!!若 ! 的右子树高则交换其左、右子树 !! #$(! #$%&$* #$’ $! #$.#/0* #$’)’+,-(! #$%&$*,! #$.#/0*); ! #$’ "! #$.#/0* #$’ %5; } .&*6.) !; } 上述算法的基本思想是沿左偏高树 ! 的右链,递归地进行子树合并。将左偏树 " 与 ! 的右 子树合并后,若 ! 的右子树高则交换其左、右子树,以维持树的左偏高性质。 由左偏高树的性质!可知,有 # 个元素的左偏高树的右链长为 $(%2/#)。合并算法在右链 的每个结点处耗时 $(5),因此,算法 12)3,*&),*& 所需的计算时间为 $(%2/#)。 要在左偏高树中插入一个元素 !,可先创建存储元素 ! 的单结点左偏高树,然后将新创建的 单结点左偏高树与待插入的左偏高树合并即可。 72#8 9:;<=)’&.*(>&*=*&( !,?#)9:;< 9) { %*%#)@ A "B&+9:;<)28&(!,5); 9 #$.22* "12)3,*&),*&(9 #$.22*,A); } 由于算法 12)3,*&),*& 的计算时间为 $(%2/#),所以算法 9:;<=)’&.*(!,9)所需的计算时间 为 $(%2/#)。 9:;&*=*&( 9:;$"’>$"(-,0); 3"$?3’ 1; } !"#"$"%&’(()所需的计算时间显然也是 "(#4:#)。 左偏高树的建树运算用给定数组 $ 中的 # 个元素创建 @ 棵存储这 # 个元素的左偏高树。如 果用逐次将元素插入左偏高树的方法,则需要 "(##4:#)时间。下面的建树方法只需要 "(#)时 间。实现方法如下: %&’(,-. ,?&#A(,-.()"$*$"+ >[],&’$ ’) { !!左偏高树的建树算法 !! &’$ &; #$#&’/ B,=,C; D?"?" D "D?"?"*’&$(); %&’(,-. ( "(,-.*’&$(); !!初始化左偏高树队列 !! 243(& "@;& ’"’;& %%){ !!创建单结点树 !! C "E"F(,-.’4A"(>[&],@); 6’$"3D?"?"(C,D); } !!依队列顺序合并左偏高树 !! 243(& "@;& ’"’ #@;& %%){ !!从队列中删除 G 棵左高树并合并之 !! B "!"#"$"D?"?"(D); = "!"#"$"D?"?"(D); !"# 数据结构与算法 ! ""#$%&’($&’((!,%); !!合并后的新左高树入队 !! )$’(*+,(,((!,+); } -.($)/ #$*##’ "0(1(’(+,(,((+); *(’,*$ /; } 上述算法先创建存储所给 ! 个元素的 ! 棵单结点左偏高树,并将这 ! 棵树存入一个队列 " 中,然后依队列顺序逐次合并队首的 2 棵左高树,直至队列 " 中只剩下 3 棵树时为止。 上述算法合并了 ! # 2 棵单结点树,! # 4 棵 2 结点树,! # 5 棵 4 结点树,⋯ 。合并 2 棵 2$ 结点的 左偏高树需要 %($ & 3)时间。因此上述初始化算法所需的计算时间为 %(! # 2 & 2 !(! # 4)& 6 !(! # 5)& ⋯ )’ % !# - 2 ( )- 7 8($) 9 9 左偏重树的实现是类似的。 33: ;9 应9 9 用 哈夫曼编码算法:哈夫曼编码是广泛地用于数据文件压缩的一个十分有效的编码方法。其 压缩率通常在 2<= > ?<= 之间。哈夫曼编码算法使用一个字符在文件中出现的频率表来建立 一个用 <,3 串表示各字符的最优表示方式。假设有一个数据文件包含 3<< <<< 个字符,要用压 缩的方式来存储它。该文件中各字符出现的频率如表 33: 3 所示。即文件中共有 ; 个不同字符 出现。字符 & 出现 4@ <<< 次,字符 ! 出现 36 <<< 次等。 表 !!" !# 字符出现的频率表 项9 9 目 & ! % A ( . 频率(千次) 4@ 36 32 3; ? @ 定长码 <<< <<3 <3< <33 3<< 3<3 变长码 < 3<3 3<< 333 33<3 33<< 要表示这样一个文件中的信息有多种方法。考察用 <、3 码串表示字符的方法,即每个字符 用惟一的一个 <、3 串来表示。若使用定长码,则表示 ; 个不同的字符需要 6 位:( ’ <<<, ) ’ <<3,⋯ ,* ’ 3<3。用这种方法对整个文件进行编码需要 6<< <<< 位。使用变长码要比使用定 长码好得多。给出现频率高的字符较短的编码,出现频率较低的字符以较长的编码,可以大大缩 短总码长。表 33: 3 给出了一种变长码编码方案。其中,字符 ( 用 3 位串 < 表示,而字符 * 用 4 位 串 3 3<< 表示。用这种编码方案,整个文件的总码长为:(4@ B 3 C 36 B 6 C 32 B 6 C 3; B 6 C ? B 4 C @ B 4)B 3 <<< 7 224 <<< 位。它比用定长码方案好,总码长减少约 2@= 。事实上,这是该文件 的一个最优编码方案。 !"#第 33 章9 优 先 队 列 !" 前缀码 对每一个字符规定一个 #、! 串作为其代码,并要求任一字符的代码都不是其他字符代码的 前缀,称这样的编码具有前缀性质,或简称为前缀码。编码的前缀性质可以使译码方法非常简 单。由于任一字符的代码都不是其他字符代码的前缀,从编码文件中不断取出代表某一字符的 前缀,转换为原字符,即可逐个译出文件中的所有字符。例如表 !!" ! 中的变长码就是一种前缀 码。对于给定的 #、! 串 ##!#!!!#! 可惟一地分解为 #,#,!#!,!!#!,因而其译码为 $$%&。 译码过程需要方便地取出编码的前缀,因此需要一个表示前缀码的合适的数据结构。为此 目的,可以用二叉树作为前缀编码的数据结构。在表示前缀码的二叉树中,树叶代表给定的字 符,并将每个字符的前缀码看作是从树根到代表该字符的树叶的一条道路。代码中每一位的 # 或 ! 分别作为指示某结点到左儿子或右儿子的“路标”。 容易看出,表示最优编码方案所对应的前缀码的二叉树总是一棵完全二叉树,即树中任一结 点都有 ’ 个儿子。定长编码方案不是最优的,其编码二叉树不是一棵完全二叉树。在一般情况 下,若 ! 是编码字符集,则表示其最优前缀码的二叉树中恰有( ! ( 个叶子。每个叶子对应于字符 集中一个字符,且该二叉树恰有( ! ( ) ! 个内部结点。 给定编码字符集 ! 及其频率分布 ",即 ! 中任一字符 * 以频率 "(#)在数据文件中出现。! 的 一个前缀码编码方案对应于一棵二叉树 $。字符 * 在树 $ 中的深度记为 %$(#)。%$(#)也是字符 * 的前缀码长。 该编码方案的平均码长定义为:&($)’ ##%! "(#)%$(#)。 使平均码长达到最小的前缀码编码方案称为 ! 的一个最优前缀码。 ’" 构造哈夫曼编码 哈夫曼提出了一种构造最优前缀码的贪心算法,由此产生的编码方案称为哈夫曼编码。哈 夫曼算法以自底向上的方式构造表示最优前缀码的二叉树 $。算法以 ( ! ( 个叶结点开始,执行 ( ! ( ) ! 次的“合并”运算后产生最终所要求的树 $。下面所给出的算法 +,--.$/01&& 中,编码字 符集中每一字符 * 的频率是 "(#)。以 " 为键值的优先队列 ( 用以在作贪心选择时有效地确定算 法当前要合并的 ’ 棵具有最小频率的树。一旦 ’ 棵具有最小频率的树合并后,产生一棵新的树, 其频率为合并的 ’ 棵树的频率之和,并将新树插入优先队列 (。 算法中用到的结构类型 +,--.$/ 定义为: 234&5&- 621,*2 7/85& { 9/2 :&9;72; <9/$1301&& 21&&; }+,--.$/; 算法 +,--.$/01&& 描述如下: <9/$1301&& +,--.$/01&&(9/2 -[],9/2 /) { !"# 数据结构与算法 !"# !; $%&’ (; !!生成单结点树 !! $)**+&" ,,-,!. "+&//01((" %2)!3!4%0*($)**+&")); 5!"&6-76%% 4,4%60; 4%60 "5!"&6-8"!#(); *06(! "2;! ’"";! %%){ 4 "5!"&6-8"!#(); 9&:%76%%(!,4,4%60,4%60); .[!]; .%!<=# "*[!]; .[!]; #6%% "4; } !!建优先队列 !! ( "5)!/>$%&’(.,","); !!反复合并最小频率树 !! *06(! "2;! ’";! %%){ , "?%/%#%9!"((); - "?%/%#%9!"((); 4 "5!"&6-8"!#(); 9&:%76%%(@,4,,; #6%%,-; #6%%); ,; .%!<=# % "-; .%!<=#;,; #6%% "4; $%&’8"3%6#(,,(); } , "?%/%#%9!"((); 6%#)6" ,; #6%%; } 算法 $)**+&"76%% 首先用字符集 ! 中每一字符 1 的频率 "(#)初始化优先队列 $。然后不断 地从优先队列 $ 中取出具有最小频率的 A 棵树 % 和 &,将它们合并为一棵新树 ’。’ 的频率是 % 和 & 的频率之和。新树 ’ 以 % 为其左儿子,& 为其右儿子。(也可以 & 为其左儿子,% 为其右儿 子。不同的次序将产生不同的编码方案,但平均码长是相同的。)经过 ( ) 2 次的合并后,优先队 列中只剩下一棵树,即所要求的树 *。 算法 $)**+&"76%% 用最小堆来实现优先队列 $。初始化优先队列需要 +(()计算时间,由于 ?%/%#%9!" 和 $%&’8"3%6# 只需 +(/0<()时间,( ) 2 次的合并总共需要 +((/0<()计算时间。因此, 关于 ( 个字符的哈夫曼算法的计算时间为 +((/0<()。 B; 哈夫曼算法的正确性 要证明哈夫曼算法的正确性,只要证明最优前缀码问题具有贪心选择性质和最优子结构 性质。 !!"第 22 章C 优 先 队 列 (!)贪心选择性质 设 ! 是编码字符集,! 中字符 " 的频率为 "(#)。又设 $ 和 % 是 ! 中具有最小频率的 # 个字 符,则存在 ! 的一个最优前缀码使 $ 和 % 具有相同码长且仅最后一位编码不同。 证明:设二叉树 & 表示 ! 的任意一个最优前缀码。要证明可以对 & 作适当修改后得到一棵 新的二叉树 & $,使得在新树中,$ 和 % 是最深叶子且为兄弟。同时新树 & $表示的前缀码也是 ! 的一个最优前缀码。如果能做到这一点,则 $ 和 % 在 &$表示的最优前缀码中就具有相同的码长 且仅最后一位编码不同。 设 ’ 和 # 是二叉树 & 的最深叶子且为兄弟。不失一般性可设 "(’)$"(#),"($)$"(%)。由 于 $ 和 % 是 ! 中具有最小频率的 # 个字符,故 "($)$"(’),"(%)$"(#)。 首先在树 & 中交换叶子 ’ 和 $ 的位置得到树 &%,然后在树 &%中再交换叶子 # 和 % 的位置,得 到树 & $,如图 !!& ’ 所示。 图 !!& ’( 编码树 & 的变换 由此可知,树 & 和 &%表示的前缀码的平均码长之差为 ((&)) ((&*)+ ##%! "(#),&(#)) ##%! "(#),&*(#) + "($),&($)- "(’),&(’)) "($),&*($)) "(’),&*(’) + "($),&($)- "(’),&(’)) "($),&(’)) "(’),&($) +("(’)) "($))(,&(’)) ,&($))& ) ( ( 最后一个不等式是因为 "(’)) "($)和 ,&(’)) ,&($)均为非负。 类似地,可以证明在 &%中交换 % 与 # 的位置也不增加平均码长,即 ((&%)* ((& $)也是非负 的。由此可知 ((& $)$((&%)$((&)。另一方面,由于 & 所表示的前缀码是最优的,故 ((&)$ ((& $)。因此,((&)+ ((& $),即 & $表示的前缀码也是最优前缀码,且 $ 和 % 具有最长的码长, 同时仅最后一位编码不同。 (#)最优子结构性质 设 & 是表示字符集 ! 的一个最优前缀码的完全二叉树。! 中字符 " 的出现频率为 "(#)。设 $ 和 % 是 树 & 中 的 # 个 叶 子 且 为 兄 弟,. 是 它 们 的 父 亲。 若 将 . 看 作 是 具 有 频 率 "(.)+ "($)- "(%)的字符,则树 &% + & ){$,%}表示字符集 !% + ! ){$,%}’{.}的一个最优前缀 码。 证明:首先证明 & 的平均码长 ((&)可用 &%的平均码长 ((&%)来表示。 事实上,对任意 # % ! ){$,%}有 ,&(#)+ ,&*(#),故 "(#),&(#)+ "(#),&*(#)。 !"! 数据结构与算法 另一方面,!"(#)$ !"(%)$ !"&(’)( ! ,故 )(#)!"(#)( )(%)!"(%)$()(#)( )(%))(!"&(’)( !) $ )(#)( )(%)( )(’)!"&(’) " " 由此即知,*(")$ *("&)( )(#)( )(%)。 若 "#所表示的字符集 +#的前缀码不是最优的,则有 " $表示的 +#的前缀码使得 *(" ,)% *("#)。由于 ’ 被看作是 +#中的一个字符,故 ’ 在 " $中是一树叶。若将 # 和 % 加入树 " $中作为 ’ 的儿子,则得到表示字符集 + 的前缀码的二叉树 "!,且有 *("!)$ *(",)( )(#)( )(%) - *("&)( )(#)( )(%)$ *(") 这与 " 的最优性矛盾。故 "#所表示的 +#的前缀码是最优的。 由贪心选择性质和最优子结构性质并用数学归纳法立即可推出:哈夫曼算法是正确的,即 &’(()*+,-.. 产生 + 的一棵最优前缀编码树。 本 章 小 结 本章讲授的主题是以集合为基础的抽象数据类型优先队列及其实现方法。优先队列支持的 主要集合运算是插入运算和删除最小元运算。由于优先队列与字典的相似性,所有实现字典的 方法都可用于实现优先队列,但用优先级树和堆来实现优先队列效率更高。本章介绍了用数组 实现的极小化堆。当堆中有 . 个元素时,插入运算和删除最小元运算在最坏情况下所需时间均 为 /(/01.)。利用堆结构还可以设计出对给定的 . 个元素就地排序的堆排序算法。在最坏情况 下,堆排序算法只需要作 /(./01.)次元素键值的比较。可并优先队列也是以集合为基础的抽象 数据类型。除了必须支持优先队列的插入运算和删除最小元运算外,可并优先队列还支持 2 个 不同优先队列的合并运算。左偏树是实现可并优先队列的高效数据结构。对于含有 . 个元素的 集合,左偏树可以在最坏情况下用 /(/01.)时间完成可并优先队列支持的插入运算、删除最小元 运算和 2 个不同优先队列的合并运算。 习" " 题 !!" !# 说明如何以有序数组为基本数据结构来实现抽象数据类型优先队列。 !!" $# 说明如何以单链表为基本数据结构来实现抽象数据类型优先队列。 !!" %# 说明如何以有序链表为基本数据结构来实现抽象数据类型优先队列。 !!" &# 在以有序链表为基本数据结构的优先队列中,可以采用下面的懒排序技术。算法在执行 34+ 和 5./.6.34+ 运算时将链表排序。在执行回 7+8.-6 运算时,将新近插入的元素保存在另一个无序链表中。仅当执行 34+ 和 5./.6.34+ 运算时将无序链表中的元素排序并合并到主链表中。试设计实现上述思想的算法,并讨论其 效率及其优缺点。 !!" ’# 说明如何用优先队列来实现栈和队列。 !!" (# 试设计并实现极大化堆。 !!" )# 一个已按非增序排好序的数组是一个极小化堆吗? !!" *# 设数组 0 是一个有 !9 个元素的极小化堆,且堆中各元素的键值互不相同。该堆中的最小元素在 !"#第 !! 章" 优 先 队 列 ![!]中;第 " 小元素在 !["]或 ![#]中。对于 " # ",#,$ 回答: (!)该堆中第 " 小元素可以在哪些位置出现。 (")该堆中第 " 小元素不可以在哪些位置出现。 !!" #$ 设数组 ! 是一个有 $ 个元素的极小化堆,且堆中各元素的键值互不相同。对于任意 !$"$$ 回答: (!)该堆中第 " 小元素可以在哪些位置出现。 (")该堆中第 " 小元素不可以在哪些位置出现。 !!" !%$ 对于极大化堆重做习题 !!% &。 !!" !!$ 试设计并实现极大化左偏高树。 !!" !&$ 试设计并实现左偏重树,分析各运算所需的计算时间。 !"# 数据结构与算法 书书书 第 !" 章# 并# 查# 集 学习目标 ! 理解以不相交的集合为基础的抽象数据类型并查集。 ! 掌握用数组实现并查集的方法。 ! 掌握用树结构实现并查集的方法。 ! 理解将小树合并到大树的合并策略及其实现。 ! 掌握路径压缩技术及其实现方法。 !"# !$ 并查集的定义及其简单实现 在一些应用问题中,需将 ! 个不同的元素划分成一组不相交的集合。开始时,每个元素自成 一个单元素集合,然后按一定顺序将属于同一组元素的集合合并。其间要反复用到查询某个元 素属于哪个集合的运算,适合于描述这类问题的抽象数据类型称为并查集。它的数学模型是一 组不相交的动态集合的集合 " #{$,%,&,⋯ },它支持以下的运算: ! %&’()*((+,,,%):将并查集 ’ 中的集合 $ 和 % 合并,其结果取名为 $ 或 %。 " %&-)(.(/):找出包含元素 ( 的集合,并返回该集合的名字。 并查集的一个应用是确定集合上的等价关系。在第 0 章中讨论了用栈来解离线等价关系问 题。利用抽象数据类型并查集,可按下述方式来解等价关系问题。首先,将集合 " 中每个元素初 始化为一个单元素集。然后逐个处理每个等价性条件。当处理等价性条件 )"* 时,先用 %&-)(. 将 ) 和 * 所属的集合找出来,再用 %&’()*( 将找出的集合进行合并。 注意,在并查集中需要 " 种类型的参数:集合名字的类型和元素的类型。在许多情况下,可 以用整数作集合的名字。如果集合中共有 ! 个元素,可以用范围 ! 1 ! 以内的整数来表示元素。 实现并查集的一个简单方法是使用数组来表示元素及其所属子集的关系。其中用数组下标表示 元素,用数组单元记录该元素所属的子集名字。如果元素类型不是整型,则可以先构造一个映 射,将每个元素映射成一个整数。这种映射可以用散列表或其他方式来实现。 用数组实现的并查集可描述如下: !"#$%$& ’!()*! )&’$!!+,’$!; !"#$%$& ’!()*! )&’$!{ -.! !*/0#/.$.!’; -.! .; }+,1; 其中,整数 ! 是集合中元素的个数。*/0#/.$.!’ 是表示元素及其所属子集关系的数组,*/0#/2 .$.!’[3]表示元素 " 当前所属的集合的名字。 函数 +,-.-!(’-4$)将 */0#/.$.!’ 初始化成大小为 ’-4$ 的单元素集合。实现方法如下: +,’$! +,-.-!(-.! ’-4$) { -.! $; +,’$! + !0566/*(’-4$/& !+); + "#*/0#/.$.!’ !0566/*((’-4$ $7)!’-4$/&(-.!)); &/(($ !7;$ %!’-4$;$ $$)+ "#*/0#/.$.!’[$]!$; + "#. !’-4$; ($!)(. +; } 在并查集的这种表示法下,其基本运算很容易实现。+,&-.%($)的值就是 */0#/.$.!’[$]实 现方法如下: -.! +,&-.%(-.! $,+,’$! +) { ($!)(. + "#*/0#/.$.!’[$]; } +,).-/.(-,8,+)也容易实现如下: -.! +,).-/.(-.! -,-.! 8,+,’$! +) { -.! 9; &/((9 !7;9 %!+ "#.;9 $$) -&(+ "#*/0#/.$.!’[9] !!8)+ "#*/0#/.$.!’[9]!-; ($!)(. -; } !"# 数据结构与算法 !"# "$ 用父亲数组实现并查集 采用树结构实现并查集的基本思想是,每个集合用一棵树来表示。树的结点用于存储集合 中的元素名。每个树结点还存放一个指向其父结点的指针。树根结点处的元素代表该树所表示 的集合。利用映射可以找到集合中元素所对应的树结点。 父亲数组是实现上述树结构的有效方法。具体实现方法如下: %&’()(* +%,-.% -*+(% !/0+(%; %&’()(* +%,-.% -*+(%{ 12% !’3,(2%; }/04; 其中,’3,(2% 是表示树结构的父亲数组。元素 ! 的父结点为 ’3,(2%[5]。 函数 /0121%(+16()将每个元素初始化为一棵单结点树。实现方法如下: /0+(% /0121%(12% +16() { 12% (; /0+(% / !73889.(+16(9* !/); / "#’3,(2% !73889.((+16( $!)!+16(9*(12%)); *9,(( !!;( %!+16(;( $$)/ "#’3,(2%[(]!:; ,(%-,2 /; } 在并查集的父亲数组表示下,/0*12)((,/)运算就是从元素 " 相应的结点走到树根处,找出 所在集合的名字。实现方法如下: 12% /0*12)(12% (,/0+(% /) { ;<18((/ "#’3,(2%[(])( !/ "#’3,(2%[(]; ,(%-,2 (; } 用 /0-2192(1,=,/)合并 " 个集合,只要将表示其中一个集合的树的树根改为表示另一个集 合的树的树根的儿子。实现方法如下: !"#第 !" 章$ 并$ 查$ 集 !"# $%&"!’"(!"# !,!"# (,$%)*# $) { $ "#+,-*"#[(]!!; -*#&-" !; } 容易看出,在最坏情况下,合并可能使 ! 个结点的树退化成一条链。在这种情况下,对所有 元素各执行一次 $%.!"/ 将耗时 "(!0 )。所以,尽管 $%&"!’" 只需要 "(1)时间,但 $%.!"/ 可能使 总的时间耗费很大。为了克服这个缺点,可以作下述改进,使得每次 $%.!"/ 不超过 "(2’3!)时 间。在树根中保存该树的结点数,每次合并时总是将小树合并到大树上去。当一个结点从一棵 树移到另一棵树上去时,这个结点到树根的距离就增加 1,而这个结点所在的树的大小至少增加 一倍。于是并查集中每个结点至多被移动 "(2’3!)次,从而每个结点到树根的距离不会超过 " (2’3!)。所以每次 $%.!"/ 运算只需要 "(2’3!)时间。 在下面所描述的改进的并查集结构中增加了一个根结点数组 -’’#,用来记录树的根结点。 当元素 # 所在结点不是根结点时,-’’#[*]4 5,+,-*"#[*]表示其父结点;当元素 # 所在结点是根结 点时,-’’#[*]4 1,+,-*"#[*]的值是树中结点个数。 #6+*/*. )#-&7# &.)*# !$%)*#; #6+*/*. )#-&7# &.)*#{ !"# !+,-*"#; !"# !-’’#; }$%8; 函数 $%!"!#()!9*)将每个元素初始化为一棵单结点树。实现方法如下: $%)*# $%!"!#(!"# )!9*) { !"# *; $%)*# $ !:,22’7()!9*’. !$); $ "#+,-*"# !:,22’7(()!9* $1)!)!9*’.(!"#)); $ "#-’’# !:,22’7(()!9* $1)!)!9*’.(!"#)); .’-(* !1;* %!)!9*;* $$){ $ "#+,-*"#[*]!1; $ "#-’’#[*]!1; } -*#&-" $; } !"# 数据结构与算法 !"#$%&(’,!)运算从元素 ! 相应的结点走到树根处,找出所在集合的名字。实现方法如下: $%( !"#$%&($%( ’,!")’( !) { *+$,’(!! "#-..([’])’ !! "#/0-’%([’]; -’(1-% ’; } 改进后的 !"1%$.%($,2,!)运算将小树合并到大树上去。实现方法如下: $%( !"1%$.%($%( $,$%( 2,!")’( !) { $#(! "#/0-’%([$]%! "#/0-’%([2]){ ! "#/0-’%([2]$ !! "#/0-’%([$]; ! "#-..([$]!3; ! "#/0-’%([$]!2; -’(1-% 2;} ’,)’ { ! "#/0-’%([$]$ !! "#/0-’%([2]; ! "#-..([2]!3; ! "#/0-’%([2]!$; -’(1-% $;} } 加速并查集运算的另一个办法是采用路径压缩技术。在执行 !"#$%& 时,实际上找到了从一 个结点到树根的一条路径。路径压缩就是把这条路上的所有结点都改为树根的儿子。实现路径 压缩的最简单的方法是在这条路上走 4 次,第 5 次找到树根,第 4 次将路上所有结点的父结点都 改为树根。 $%( !"#$%&($%( ’,!")’( !) { $%( $,2 !’; *+$,’(!! "#-..([2])2 !! "#/0-’%([2];&!找树根 !& *+$,’(2!!’){&!路径压缩 !& $ !! "#/0-’%([’]; ! "#/0-’%([’]!2; ’ !$; } !"#第 54 章6 并6 查6 集 !"#$!% &; } 路径压缩并不影响 ’($%)*% 运算的时间,它仍然只要 !(+)时间。但是路径压缩大大地加速 了 ’(,)%- 运算。如果在执行 ’($%)*% 时总是将小树并到大树上,而且在执行 ’(,)%- 时,实行路 径压缩,则可以证明," 次 ’(,)%- 至多需要 !("!("))时间。其中 !(")是单变量阿克曼函数的 逆,它是一个增长速度比 .*/" 慢得多但又不是常数的函数。对于通常见到的正整数 " 而言, !(")#0。 +12 34 应4 4 用 离线最小值问题:给定集合 # 5{+,1,⋯ ,"},以及由 " 个 6%7"!#(8,9)和 $ 个 :"."#";)%(9) 运算组成的运算序列。其中 " 个 6%7"!# 运算将集合 # 5{+,1,⋯ ,"}中每个数插入动态集合 % 恰 好一次,:"."#";)%(9)每次删除动态集合 % 中的最小元素。离线最小值问题要求对于给定的运 算序列,计算出每个 :"."#";)%(9)运算输出的值。换句话说,要求计算数组 *$#,使第 & 次 :"."#"< ;)%(9)运算输出的值为 *$#[)],& 5 +,1,⋯ ,$。在执行具体计算前,运算序列已给定,这就是问 题表述中离线的含义。 为了计算输出数组 *$# 的值,可以用一个优先队列 ’,按照给定的运算序列依次执行 " 个 6%< 7"!#(8,9)和 $ 个 :"."#";)%(9)运算,将第 & 次 :"."#";)%(9)运算的结果记录到 *$#[)]中。执行 完所给的运算后,数组 *$# 即为所求。按照上述思路设计的算法描述如下: =*)- *%>)%()%# )%[],)%# "[],)%# *$#[],)%# %,)%# ?) { )%# ),@$!! !+; A"BC A !;)%A"BC6%)#(%); ,*!() !+;) %!?;) $$){ DE)."(@$!! %!"[)])A"BC6%7"!#()%[@$!! $$],A); *$#[)]!:"."#";)%(A); } } 在最坏情况下,上述算法需要 !($.*/")计算时间。当 $ ( "(")时,算法需要的计算时间 为 !(".*/")。实际上,上述算法是一个在线算法,即每次处理一个运算,并不要求事先知道运 算序列。因而算法没有用到问题的离线性质。利用并查集和问题的离线性质可以将算法的计算 时间进一步减少为 !("!("))。 将给定的 " 个 6%7"!# 和 $ 个 :"."#";)% 运算组成的运算序列表示为 )+ *)1 *)3 *⋯ )+ *)+,+ !"" 数据结构与算法 其中,!" ,! # " # # $ ! ,为连续若干个(可以为 ")#$%&’( 运算组成的运算序列,) 表示 )&*&(&+,$ 运算。下面用并查集算法模拟这个运算序列。开始时,将 !" 中的 #$%&’( 运算插入动态集合 % 中 的元素用 -./$,0$ 运算组织成一个集合,并将该集合记为第 " 个集合,! # " # # $ ! 。由于第 " 个 集合的名字与其序号可能不同,算法中用 1 个数组 %, 和 ,% 来表示集合名与其序号的对应关系。 例如,第 " 个集合名为 $23& 时,%,[$23&]4 5 且 ,%[5]4 $23&。另外,算法中还用到 1 个数组 6’&7 和 $&8( 来表示 !" 之间的顺序。开始时,6’&7[5]4 5 9 !,! # " # # $ ! 且 $&8([5]4 5 : !," # " # # 。接下来,算法对每 &,!#&#’,用 -.;,$< 运算计算出集合序号 ",使得 &$!" 。这表明第 " 个 )&*&(&+,$ 运算输出元素 &,即 0/([5]4 ,。然后用 -./$,0$ 运算将集合 !" 与集合 !" $ ! 合并,并修改 数组 6’&7 和 $&8( 的值,将 " 从链表中删除。算法结束后,输出数组 0/( 给出正确的计算结果。 70,< 0;;3,$(,$( ,$[],,$( &[],,$( 0/([],,$( $,,$( =) { ,$( ,,5,!%,,!,%,!6’&7,!$&8(; -.%&( - !-.,$,(($); %, !32**0>(($ $1)!%,?&0;(,$()); ,% !32**0>(($ $1)!%,?&0;(,$()); 6’&7 !32**0>((= $1)!%,?&0;(,$()); $&8( !32**0>((= $1)!%,?&0;(,$()); ;0’(, !!;, %!=;, $$){ ,$( >/’’ !(&[,]#&[, "!])?,$[&[, "!]$!]:"; ,;(&[,]%,’’ &[,]%&[, "!])@’’0’((A2< #$6/((); ;0’(5 !&[, "!]$1;5 %!&[,];5 $$)>/’’ !-./$,0$(>/’’,,$[5],-); %,[>/’’]!,; ,%[,]!>/’’; } ;0’(, !";, %!=;, $$){6’&7[, $!]!,;$&8([,]!, $!;} ;0’(, !!;, %!$;, $$){ ,$( $23& !-.;,$<(,,-); 5 !%,[$23&]; ,;(5 %!=){ ,$( $&B%&( !$23&; ,;(,%[$&8([5]])$&B%&( !-./$,0$($23&,,%[$&8([5]],-); %,[$&B%&(]!$&8([5]; ,%[$&8([5]]!$&B%&(; $&8([6’&7[5]]!$&8([5]; 6’&7[$&8([5]]!6’&7[5]; 0/([5]!,; !""第 !1 章C 并C 查C 集 } } } 上面的算法中用 ! 个数组 "# 和 $ 表示输入序列。"# 给出 ! 个元素的插入序列,$ 给出 %$&$’$( )"# 运算在插入序列中的位置。例如,给定的插入元素和 %$&$’$)"# 运算序列为{*,+,%,!,%,,,%} 时,有 ! "+ 且 # "*。此时,"# -[*,+,!,,]且 $ -[!,*,+];$, -[*,+],$! -[!],$* -[,],$+ -[]。第 , 次执行算法主循环体时,% -,,此时找到 & -*,即 ,$$* 。由此可知 ./’[*]-,。算法将集合 $* 与 $+ 合并后 $+ "[,]。当 % "! 时,找到 & "!,即 !$$! ,由此得 ./’[!]-!。算法将集合 $! 与 $+ 合并后得 $+ -[,,!]。同理当 % "* 时,计算出 & -,。算法最后输出 ./’ -[*,!,,]。 上述算法的主要计算量在于其主循环中的 ! 个 012"#3 运算。如果在执行 01/#".# 时总是将 小树并到大树上,而且在执行 012"#3 时,实行路径压缩,则 ! 次 012"#3 至多需要 ’(!!(!))时 间。算法其余部 分 所 需 要 的 计 算 时 间 为 ’( !)。由 此 可 见,上 述 算 法 需 要 的 总 计 算 时 间 为 ’(!!(!))。 本 章 小 结 本章讲授的主题是以不相交的集合为基础的抽象数据类型并查集及其实现方法。并查集支 持的主要集合运算是集合查询和集合合并运算。用数组容易实现并查集,但其集合合并运算效 率较低。用树结构实现并查集使得集合合并运算只需要 ’(,)时间。在最坏情况下,合并运算 可能使 ! 个结点的树退化成一条链。在这种情况下,对所有元素各执行一次 012"#3 将耗时 ’(!! )。为了克服这个缺点,在合并时采用将小树合并到大树的合并策略可使每次 012"#3 不超 过 ’(&.4!)时间。进一步采用路径压缩技术可以使得 012"#3 需要的平均时间降至 ’(!(!))。 习5 5 题 !"# !$ 假设开始时有 ! 个单元素集合,试证明: (,)经过 ( 次 01/#".# 运算后,任一集合中元素个数不超过 ( ) ,。 (!)最多需要 ! 6 , 次 01/#".# 运算可将 ! 个单元素集合合并为一个 ! 元素集合。 (*)如果执行 01/#".# 运算次数小于 !* ! ,则所剩集合中至少有一个单元素集合。 (+)如果执行了 ( 次 01/#".# 运算,则至少还有 789{! + !(,:}个单元素集合。 !"# "$ 在执行改进的 01/#".# 运算时,采用将小树合并到大树的策略。如果采用将矮树合并到高树的策 略,则算法效率如何? !"# %$ 在执行改进的 012"#3 运算时,可以采用路径分割技术,即在元素 , 到根的路上,将每个结点(除了根 结点及其儿子结点外)的父结点指针改为指向其祖父结点。试用此技术重写算法 012"#3,并分析算法的总体 效率。 !"# &$ 在执行改进的 012"#3 运算时,可以采用半路径分割技术,即在元素 , 到根的路上,将相隔结点(除了 根结点及其儿子结点外)的父结点指针改为指向其祖父结点。试用此技术重写算法 012"#3,并分析算法的总体 效率。 !!! 数据结构与算法 !"# $% 试说明如何利用并查集来计算一个无向图中连通子图的个数。 !"# &% ! 是直线上 " 个带权区间的集合。从区间 #$! 到区间 $$! 的一条路是 ! 的一个区间序列 $(!), $("),⋯ ,$(%),其中 $(!) & #,$(%) & $,且对所有 !# ’# % ( !,$(’)与 $(’ ) !)相交。这条路的长度定义为 路上各区间权之和。在所有从 # 到 $ 的路中,路长最短的路称为从 # 到 $ 的最短路。带权区间图的单源最短路 问题要求计算从 ! 中一个特定的源区间到 ! 中所有其它区间之间的最短路。设计解此问题的有效算法。 !"# ’% 给定 " 个单位时间任务,以及这 " 个任务间的 * 个先后次序。现在要在 " 台相同的机器上安排这 " 个任务,使总完成时间最早。试设计解此问题的有效算法。 !""第 !" 章# 并# 查# 集 书书书 第 !" 章# 图 学习目标 · 理解图的定义和与图相关的有向图、无向图、赋权图、连通图等术语。 · 理解图是一个表示复杂非线性关系的数据结构。 · 掌握图的邻接矩阵表示及其实现方法。 · 掌握图的邻接表表示及其实现方法。 · 了解图的紧缩邻接表表示方法。 · 掌握图的广度优先搜索方法。 · 掌握图的深度优先搜索方法。 · 掌握单源最短路径问题的 !"#$%&’( 算法。 · 掌握所有顶点对之间最短路径问题的 )*+,- 算法。 · 掌握构造最小支撑树的 .’"/ 算法。 · 掌握构造最小支撑树的 0’1%$(* 算法。 · 理解图的最大匹配问题的增广路径算法。 234 25 图的基本概念 在计算机科学与技术领域中,常常需要表示不同事物之间的关系。图是描述这类关系的一 个很自然的模型。由于客观事物之间的关系往往是千变万化、错综复杂的,因此借以表达这类关 系的图也是千变万化和错综复杂的。 在线性表结构中,结点之间的关系是线性关系,除了起始结点和终止结点外,每个结点只有 一个直接前驱和一个直接后继。在树形结构中,结点之间的关系实质上是层次关系。除了根结 点外,每个结点只有一个父结点,但可以有多个儿子结点。图所表示的非线性结构就更加复杂。 图中每个结点(有时也称为顶点)既可能有前驱结点也可能有后继结点,且个数不加限制。用图 可以表达复杂的关系。 下面先介绍图的基本概念,然后讨论图的表示方法,以及关于图的各种算法。 !" 图 图 ! 是由 " 和 # 两个集合所组成的 # 元组,记为 ! $(",#),其中 " 是顶点的非空有限集,# 是 " 中顶点对,即边的有限集。通常,也将图 ! 的顶点集和边集分别记为 "(!)和 #(!)。#(!) 可以是空集,此时图 ! 中只有顶点而没有边。 #" 有向图 若图 ! 中的每条边都是有方向的,则称 ! 为有向图。在有向图中,一条有向边是顶点的有 序对。例如(%,&)表示从顶点 % 指向顶点 & 的一条有向边,其中顶点 % 称为有向边(%,&)的起点, 顶点 & 称为该有向边的终点。有向图中的有向边常用带箭头的线段来表示。 例如,图 !$" ! 中的 !! 是一个有向图,该图的顶点集和边集分别为 "(!!)${!,#,$,%} #(!!)${(!,#),(!,$),(#,%),($,#),(%,$)} 图 !$" !& 图的示例 $" 无向图 若图 ! 中的每条边都是没有方向的,则称 ! 为无向图。无向图中的边表示图中顶点的无序 对。因此,在无向图中(%,&)和(&,%)表示同一条边。 例如图 !$" ! 中的 !# 是一个无向图,它的顶点集和边集分别为 "(!#)${!,#,$,%,’} #(!#)${(!,#),(!,%),(#,$),(#,’),($,%),($,’)} 在以下的讨论中,不考虑顶点到其自身的边,即若(%,&)或(&,%)是图 ! 的一条边,则要求 %!&。此外,不允许一条边在图中重复出现。换句话说,只讨论简单的图。 %" 完全图 在上述规定下,图 ! 的顶点数 ’ 和边数 ( 满足下述关系:若 ! 是无向图,则 ("("’(’ ) !)* #;若 ! 是有向图,则 ("("’(’ ) !)。恰好有 ’(’ ) !)* # 条边的无向图称为完全无向图;恰好 有 ’(’ ) !)条边的有向图称为完全有向图。显然,完全图具有最多的边数,任意一对顶点间均有 边相连。 ’" 关联 若(%,&)是一条无向边,则称顶点 % 和 & 互为邻接点,或称 % 和 & 相邻接,并称边(%,&)关联 于顶点 % 和 &,或称边(%,&)与顶点 % 和 & 相关联。若(%,&)是一条有向边,则称 & 是 % 的邻接顶 点,并称边(%,&)关联于顶点 % 和 &,或称边(%,&)与顶点 % 和 & 相关联。 )" 顶点的度 无向图中顶点 & 的度定义为关联于该顶点的边的数目,记为 +(&)。若 ! 为有向图,则以顶 !""第 !$ 章& 图 点 ! 为终点的边的数目,称为 ! 的入度,记为 "#(!);以顶点 ! 为起点的边的数目,称为 ! 的出度, 记为 $#(!);顶点 ! 的度则定义为该顶点的入度与出度之和,即 #(!)% "#(!)& $#(!)。 例如,图 !"# ! 的 ’$ 中顶点 $ 的度为 ",图 ’! 中顶点 $ 的入度为 $,出度为 !,度为 "。无论 是有向图还是无向图,顶点数 (、边数 ) 和度数之间有如下关系 ) % ! $ # ( * % ! #(!* ) %# 子图 设 ’ %(+,,)是一个图,若 +-是 + 的子集,,-是 , 的子集,且 ,-中的边所关联的顶点均在 +- 中,则 ’- %(+-,,-)也是一个图,并称其为图 ’ 的一个子图。 例如图 !"# $ 给出了图 !"# ! 中有向图 ’! 的若干子图,图 !"# " 给出了图 !"# ! 中无向图 ’$ 的若干子图。 图 !"# $& 有向图 ’! 的若干子图 图 !"# "& 无向图 ’$ 的若干子图 ’# 路 在无向图 ’ 中,若存在一个顶点序列 .(!),.($),⋯ ,.(/),使得(.(*),.(* & !))$,(’), * % !,$,⋯ ,/ 0 !,则称该顶点序列为顶点 .(!)和 .(/)之间的一条路径。其中 .(!)称为该路径 的起点,.(/)称为该路径的终点。这条路径所包含的边数 / 0 ! 称为该路径的长度。 若图 ’ 是有向图,则路径也是有向的,其中每条边(.(*),.(* & !)),* % !,$,⋯ ,/ 0 ! 均为有 向边。 (# 简单路 若一条路径上除了起点和终点可能相同外,其余顶点均不相同,则称此路径为一条简单 路径。 !)# 回路 起点和终点相同的简单路径称为简单回路或简单环或圈。 例如,图 !"# ! 的 ’! 中,顶点序列 ",$,*," 组成一条长度为 " 的简单回路。 !!# 有根图 在一个有向图中,若存在一个顶点 !,从该顶点有路径可以到达图中其他所有顶点,则称此 有向图为有根图,! 称为该有根图的根。 例如,图 !"# ! 中的 ’! 为有根图,顶点 ! 为 ’! 的根。 !"" 数据结构与算法 !"# 连通图 在无向图 ! 中,若从顶点 " 到顶点 # 有一条路径,则称顶点 " 和 # 在图 ! 中是连通的。若 $ (!)中任意 " 个不同的顶点 " 和 # 都是连通的,则称 ! 为连通图。 例如,图 !$# ! 中的 !" 为一个连通图。 !$# 连通分支 无向图 ! 的极大连通子图称为 ! 的连通分支。显然,任何连通图只有一个连通分支,即其 自身。而非连通的无向图有多个连通分支。 例如,图 !$# % 中的图有 " 个连通分支 %! 和 %"。 图 !$# %& 有 " 个连通分支的图 !%# 强连通分支 在有向图 ! 中,若对于 $(!)中任意 " 个不同的顶点 " 和 #,都存在从 " 到 # 以及从 # 到 " 的 路径,则称 ! 是强连通图。有向图 ! 的极大强连通子图称为 ! 的强连通分支。显然,强连通图 只有一个强连通分支,即其自身。非强连通的有向图有多个强连通分支。 例如,图 !$# ! 中的 !! 不是强连通图,因为从顶点 " 到顶点 ! 之间没有路径,但它有 " 个强 连通分支,如图 !$# ’ 所示。 !’# 赋权图和网络 若无向图的每条边都带一个权,则称相应的图为赋权无向图。同理,若有向图的每条边都带 一个权,则称相应的图为赋权有向图。通常权是具有某种实际意义的数,比如," 个顶点之间的 距离、耗费等。赋权无向图和赋权有向图统称为网络,图 !$# ( 就是网络的一个例子。 图 !$# ’& 图 !$# ! 中 !! 的强连通分支 图 !$# (& 网络示例 !$# "& 抽象数据类型 )*+ 图 有了图的基本概念后,现在可以定义以图为数学模型的抽象数据类型 )*+ 图。为此要定义 图上的一些基本运算。由于无向图与有向图的差别仅在于无向图中的边是顶点的无序对,而有 向图中的边是顶点的有序对,所以可以将一个无向图 ! 当作一个有向图来处理,其中将 ! 的每 !""第 !$ 章& 图 一条边(!,"),用 ! 条有向边(!,")和(",!)来代替。下面定义的 "#$ 图的基本运算是以有向图 为基本模型的。 抽象数据类型 "#$ 图支持以下的基本运算: ! %&’()*+*,(+):创建 # 个孤立顶点的图。 " %&’()-.*/,(*,0,%):判断图 $ 中是否存在边(%,&)。 # %&’()-123/(%):返回图 $ 的边数。 $ %&’()43&,*53/(%):返回图 $ 的顶点数。 % %&’()"11(*,0,%):在图 $ 中加入边(%,&)。 & %&’()#363,3(*,0,%):删除图 $ 的边(%,&)。 ’ #32&33(*,%):返回图 $ 中顶点 % 的度数。 ( 78,#32&33(*,%):返回图 $ 中顶点 % 的出度。 ) 9+#32&33(*,%):返回图 $ 中顶点 % 的入度。 :;< ;= 图的表示法 表示图的方法有很多,本节仅介绍 ; 种常用的方法,至于具体选择哪一种表示法较合适,主 要取决于具体的应用以及对图所作的运算。 !"# "# !$ 邻接矩阵表示法 在图的邻接矩阵表示法中,用一个二维数组,即图的邻接矩阵来存储图中各边的信息。 图 $ 的邻接矩阵 ! 是一个布尔矩阵,定义为 ![%,&]’ := (%,&)$ (($) >= (%,&)% (($ { ) 例如,图 :;< : 中 $: 和 $! 的邻接矩阵 !: 和 !! 如图 :;< ? 所示。 图 :;< ?= $: 和 $! 的邻接矩阵 当图 $ 是一个网络时,其邻接矩阵可定义为: ![%,&]’ )(%,&)(%,&)$ (($) * (%,&)% (($ { ) 例如,图 :;< @ 中网络的邻接矩阵如图 :;< A 所示 !"" 数据结构与算法 图 !"# $% 网络的邻接矩阵 用邻接矩阵表示一个有 ! 个顶点的有向图时,所需要的空间为 !(!& )。在用邻接矩阵表示无向图时,可以利用邻接矩阵的对称性,只 存储下三角(或上三角)部分,这样可以节省一半的空间,但所需要空 间仍为 !(!& )。输入邻接矩阵和查看一遍邻接矩阵都要 !(!& )时间。 当图的边数远远小于 !& 时,用邻接矩阵来表示图就很浪费时间和空 间。在这种情况下,用邻接表来表示图会更有效。 !"# "# $% 邻接表表示法 用邻接表表示图 " #($,%)时,对每个顶点 &$$,将顶点 & 的所有 邻接顶点存放在一个表中,这个表称为顶点 & 的邻接表。将每个顶点的邻接表存储在图 " 的邻 接表数组中。 例如,图 !"# ! 中 "! 和 "& 的邻接表表示分别如图 !"# ’( 和图 !"# ’) 所示。 图 !"# ’% 图的邻接表表示 !"# "# "% 紧缩邻接表 紧缩邻接表将图 " 的每个顶点的邻接表紧凑地存储在 & 个一维数组 *+,- 和 . 中。其中一维 数组 *+,- 依次存储顶点 !,&,⋯ ,! 的邻接顶点。数组单元 .[+]存储顶点 & 的邻接表在数组 *+,- 中的起始位置。 !""第 !" 章% 图 例如,图 !"# ! 中 !! 和 !$ 的紧缩邻接表表示分别如图 !"# !%& 和图 !"# !%’ 所示。 图 !"# !%( 图的紧缩邻接表表示 !"# )( 用邻接矩阵实现图 !"# $# !% 用邻接矩阵实现赋权有向图 从图的结构和概念上看,可将图分为赋权有向图、赋权无向图、有向图和无向图 ) 种不同类 型。在上述 ) 种不同类型的图中,赋权有向图具有较一般的特征。有向图可看作是不带权的赋 权有向图。而无向图与有向图的差别仅在于无向图中的边是顶点的无序对,而有向图中的边是 顶点的有序对,所以可以将一个无向图 ! 当作一个有向图来处理,其中将 ! 的每一条边(",#),用 $ 条有向边(",#)和(#,")来代替。用邻接矩阵实现的赋权有向图结构定义如下: *+,-.-/ 0*123* 41&,5 !61&,5; 0*123* 41&,5{ ( ( ( 78*-9 :;<.4-;( ( ( !!无边标记 !! ( ( ( =>* >; !!顶点数 !! ( ( ( =>* -; !!边数 !! ( ( ( 78*-9 !!&; !!邻接矩阵 !! }?7@41&,5; 其中,数组 $ 存储赋权有向图的邻接矩阵;:;<.4- 是赋权有向图在邻接矩阵中的无边标记;% 是 赋权有向图的顶点数;& 是赋权有向图的边数。 函数 61&,5=>=*(>,>;<.4-)创建一个有 % 个孤立顶点的赋权有向图。实现方法如下: 61&,5 61&,5=>=*(=>* >,78*-9 >;<.4-) { !"# 数据结构与算法 ! ! "#$%& " "’$(()*(+,-.)/ !"); ! ! " #$0 "0; ! ! " #$. "1; ! ! " #$2)345. "0)345.; ! ! !!各顶点对间均无边 !! ! ! " #$$ "6$7.89:##$;(" #$0 %<," #$0 %<,0)345.); ! ! #.=>#0 "; } 函数 "#$%&?.#=,*.+(")和 "#$%&345.+(")分别返回赋权有向图 ! 的顶点数和边数。实现方 法如下: ,0= "#$%&345.+("#$%& ") {#.=>#0 " #$.;} ,0= "#$%&?.#=,*.+("#$%& ") {#.=>#0 " #$0;} 函数 "#$%&3@,+=(,,A,")判断当前赋权有向图 ! 中的边(",#)是否存在。实现方法如下: ,0= "#$%&3@,+=(,0= ,,,0= A,"#$%& ") { ! ! ,/(, &< ’’ A &< ’’ , $" #$0 ’’ A $" #$0 ’’ " #$$[,][A] """ #$2)345.)#.=>#0 1; ! ! #.=>#0 <; } 函数 "#$%&:44(,,A,B,")在赋权有向图 ! 中加入边权为 $ 的边(",#)。实现方法如下: C),4 "#$%&:44(,0= ,,,0= A,DE=.’ B,"#$%& ") { ! ! ,/(, &< ’’ A &< ’’ , $" #$0 ’’ A $" #$0 ’’ , ""A ’’ " #$$[,][A]!"" #$2)345.) ! ! ! ! 3##)#((F$4 ,0%>=(); ! ! " #$$[,][A]"B; ! ! " #$. %%; } 函数 "#$%&9.(.=.(,,A,")删除赋权有向图 ! 中的边(",#)。实现方法如下: !"#第 )"?(% #$’[#][/]); !"! 数据结构与算法 ! ! ! "#$%&’(()%();} } !"# $# %& 用邻接矩阵实现赋权无向图 用邻接矩阵表示赋权无向图时,将一个赋权无向图 ! 当作一个赋权有向图来处理,其中将 ! 的每一条边(",#),用 ( 条有向边(",#)和(#,")来代替。因此,函数 )#*"+,--($,.,/,))在图 ! 中 加入边权为 $ 的有向边(",#)时还应同时加入边权为 $ 的有向边(#,")。 01$- )#*"+,--($%& $,$%& .,23&45 /,)#*"+ )) { ! ! $’($ &6 ’’ . &6 ’’ $ $) #$% ’’ . $) #$% ’’ $ "". ’’ ) #$*[$][.]!") # $718-94) ! ! ! ! 8##1#((:*- $%";&(); ! ! ) #$*[$][.]"/; ! ! ) #$*[.][$]"/; ! ! ) #$4 %%; } 函数 )#*"+<4=4&4($,.,))在图 ! 中删除边(",#)时还应同时删除边(#,")。实现方法如下: 01$- )#*"+<4=4&4($%& $,$%& .,)#*"+ )) { ! ! $’($ &6 ’’ . &6 ’’ $ $) #$% ’’ . $) #$% ’’ ) #$*[$][.] "") #$718-94) ! ! ! ! 8##1#((:*- $%";&(); ! ! ) #$*[$][.]") #$718-94; ! ! ) #$*[.][$]") #$718-94; ! ! ) #$4 ##; } !"# $# "& 用邻接矩阵实现有向图 用邻接矩阵表示的有向图时,每条边的边权规定为 6。边权为 > 时表示无边。 函数 )#*"+$%$&(%)创建一个有 % 个孤立顶点的有向图。实现方法如下: )#*"+ )#*"+$%$&($%& %) !!"第 6? 章! 图 { ! ! "#$%& " "’$(()*(+,-.)/ !"); ! ! " #$0 "0; ! ! " #$. "1; ! ! " #$$ "2$3.456##$7(" #$0 %8," #$0 %8,1); ! ! #.9:#0 "; } 其他函数与赋权有向图类似。 !"# $# $% 用邻接矩阵实现无向图 用邻接矩阵表示的无向图与用邻接矩阵表示的有向图结构类似。其中每条边的边权也规定 为 8。将无向图 ! 的每一条边(",#),用 4 条有向边(",#)和(#,")来代替。因此,函数 "#$%&6;;(,, <,")在图 ! 中加入有向边(",#)时还应同时加入有向边(#,")。 =),; "#$%&6;;(,09 ,,,09 <,"#$%& ") { ! ! ,/(, &8 ’’ < &8 ’’ , $" #$0 ’’ < $" #$0 ’’ , ""< ’’ " #$$[,][<]!"1) ! ! ! ! >##)#((?$; ,0%:9(); ! ! " #$$[,][<]"8; ! ! " #$$[<][,]"8; ! ! " #$. %%; } 函数 "#$%&5.(.9.(,,<,")在图 ! 中删除边(",#)时还应同时删除边(#,")。实现方法如下: =),; "#$%&5.(.9.(,09 ,,,09 <,"#$%& ") { ! ! ,/(, &8 ’’ < &8 ’’ , $" #$0 ’’ < $" #$0 ’’ " #$$[,][<] ""1) ! ! ! ! >##)#((?$; ,0%:9(); ! ! " #$$[,][<]"1; ! ! " #$$[<][,]"1; ! ! " #$. ##; } !"# 数据结构与算法 !"# $% 用邻接表实现图 !"# $# !% 用邻接表实现有向图 用邻接表实现有向图时,将每个顶点的邻接表存储在图的邻接表数组中。邻接表结点结构 定义为: &’()*)+ ,&-./& 012*) !30415; ,&-./& 012*) { % % 41& 6;% % % % !!边的另一个顶点 !! % % 30415 1)7&; !!邻接表指针 !! % % }; 30415 8)9:82*)(41& 6,30415 1)7&) % { % % 30415 7 ";<002/(,4=)2+ !7); % % 7 #$6 "6;7 #$1)7& "1)7&; % % -)&.-1 7; % } 其中,! 是边的另一个顶点;1)7& 是邻接表指针,指向邻接表的下一个结点。函数 8)9:82*) 创建 一个新的邻接表结点。 用邻接表实现有向图的结构定义如下: &’()*)+ ,&-./& 3-<(> !?-<(>; ,&-./& 3-<(>{ % % % 41& 1;% % % % % !!顶点数 !! % % % 41& ); !!边数 !! % % % 30415 !<*@; !!邻接表数组 !! }:3-<(>; 其中,数组 <*@ 存储有向图的邻接表;" 是有向图的顶点数;# 是有向图的边数。 函数 ?-<(>414&(1)创建一个用邻接表表示的有 " 个孤立顶点的有向图。实现方法如下: ?-<(> ?-<(>414&(41& 1) { !"#第 !" 章% 图 ! ! "#$ "; ! ! %&’() % "*’++,-(."/0,1 !%); ! ! % #$# "#; ! ! % #$0 "2; ! ! % #$’34 "*’++,-((5 %6)!."/0,1(7+"#8)); ! ! 1,&(" "2;" &"5;" %%)% #$’34["]"2; ! ! &0$9&# %; } 函数 %&’():0&$"-0.(%)和 %&’();370.(%)分别返回有向图 ! 的顶点数和边数。实现方法 如下: "#$ %&’();370.(%&’() %) {&0$9&# % #$0;} "#$ %&’():0&$"-0.(%&’() %) {&0$9&# % #$#;} 函数 %&’();<".$(",4,%)判断当前有向图 ! 中的边(",#)是否存在。实现方法如下: "#$ %&’();<".$("#$ ","#$ 4,%&’() %) { ! ! 7+"#8 (; ! ! "1(" &6 ’’ 4 &6 ’’ " $% #$# ’’ 4 $% #$#)&0$9&# 2; ! ! ( "% #$’34["]; ! ! =)"+0(( >> ( #$5 !"4)( "( #$#0<$; ! ! "1(()&0$9&# 6; ! ! 0+.0 &0$9&# 2; } 函数 %&’()?33(",4,%)通过在顶点 " 的邻接表的表首插入顶点 # 来实现向有向图中加入一 条有向边(",#)。实现方法如下: 5,"3 %&’()?33("#$ ","#$ 4,%&’() %) { ! ! "1(" &6 ’’ 4 &6 ’’ " $% #$# ’’ 4 $% #$# ’’ " ""4 ’’ %&’();<".$(",4,%)) ! ! ! ! ;&&,&((@’3 "#(9$(); ! ! % #$’34["]"A0=BA,30(4,% #$’34["]); !"# 数据结构与算法 ! ! " #$# %%; } 函数 "$%&’(#)#*#(+,,,")删除有向图 ! 中的边(",#)。实现方法如下: -.+/ "$%&’(#)#*#(+0* +,+0* ,,"$%&’ ") { ! ! 1)+02 &,3; ! ! +4(+ &5 ’’ , &5 ’’ + $" #$0 ’’ , $" #$0 ’’ !"$%&’67+8*(+,,,")) ! ! ! ! 6$$.$((9%/ +0&:*(); ! ! & "" #$%/,[+]; ! ! +4(& #$- "",){" #$%/,[+]"& #$0#7*;4$##(&);} ! ! #)8# { ! ! ! ;’+)#(& << & #$0#7* #$- !",)& "& #$0#7*; ! ! ! +4(&){3 "& #$0#7*;& #$0#7* "3 #$0#7*;4$##(3);} ! ! ! } ! ! " #$# ##; } 函数 =:*(#1$##(+,")通过计算顶点 " 的邻接表长,返回有向图 ! 中顶点 " 的出度。实现方 法如下: +0* =:*(#1$##(+0* +,"$%&’ ") { ! ! 1)+02 &; ! ! +0* , ">; ! ! +4(+ &5 ’’ + $" #$0)6$$.$((9%/ +0&:*(); ! ! & "" #$%/,[+]; ! ! ;’+)#(&){, %%;& "& #$0#7*;} ! ! $#*:$0 ,; } 函数 ?0(#1$##(+,")返回有向图 ! 中顶点 " 的入度。实现方法如下: +0* ?0(#1$##(+0* +,"$%&’ ") { ! ! +0* ,,8:@ ">; ! ! +4(+ &5 ’’ + $" #$0)6$$.$((9%/ +0&:*(); !"#第 5A 章! 图 ! ! "#$(% "&;% &"’ #$(;% %%) ! ! ! )"(’$*+,-.)/0(%,),’))/12 %%; ! ! $301$( /12; } 函数 ’$*+,410(’)输出有向图 ! 的邻接表。实现方法如下: 5#)6 ’$*+,410(’$*+, ’) { ! ! 78)(9 +; ! ! )(0 ); ! ! "#$() "&;) &"’ #$(;) %%){ ! ! ! + "’ #$*6%[)]; ! ! ! :,)83(+){+$)(0"((; 6 (,+ #$5);+ "+ #$(3.0;} ! ! ! +$)(0"(()(();} } !"# $# %& 用邻接表实现无向图 用邻接表实现无向图时,将一个无向图 ! 当作一个有向图来处理,即将无向图 ! 的每一条 边(",#),用 < 条有向边(",#)和(#,")来代替。因此,函数 ’$*+,=66(),%,’)在无向图 ! 中加入有 向边(",#)时还应同时加入有向边(#,")。实现方法如下: 5#)6 ’$*+,=66()(0 ),)(0 %,’$*+, ’) { ! ! )"() && ’’ % && ’’ ) $’ #$( ’’ ! ! ! ! % $’ #$( ’’ ) ""% ’’ ’$*+,-.)/0(),%,’)) ! ! ! ! -$$#$((>*6 )(+10(); ! ! ’ #$*6%[)]"?3:@?#63(%,’ #$*6%[)]);!!加入有向边(),%)!! ! ! ’ #$*6%[%]"?3:@?#63(),’ #$*6%[%]);!!加入有向边(%,))!! ! ! ’ #$3 %%; } 函数 ’$*+,A38303(),%,’)在图 ! 中删除边(",#)时还应同时删除边(#,")。实现方法如下: 5#)6 ’$*+,A38303()(0 ),)(0 %,’$*+, ’) { !"# 数据结构与算法 ! ! "#$%& ’,(; ! ! $)($ &* ’’ + &* ’’ $ $, #$% ’’ + $, #$% ’’ !,-.’/01$23($,+,,)) ! ! ! ! 0--4-((5.6 $%’73(); ! ! ’ ", #$.6+[$];!!删除边($,+)!! ! ! $)(’ #$8 ""+){, #$.6+[$]"’ #$%913;)-99(’);} ! ! 9#29 { ! ! ! :/$#9(’ ;; ’ #$%913 #$8 !"+)’ "’ #$%913; ! ! ! $)(’){( "’ #$%913;’ #$%913 "( #$%913;)-99(();} ! ! ! } ! ! ’ ", #$.6+[+];!!删除边(+,$)!! ! ! $)(’ #$8 ""$){, #$.6+[+]"’ #$%913;)-99(’);} ! ! 9#29 { ! ! ! :/$#9(’ ;; ’ #$%913 #$8 !"$)’ "’ #$%913; ! ! ! $)(’){( "’ #$%913;’ #$%913 "( #$%913;)-99(();} ! ! ! } ! ! , #$9 ##; } !"# $# "% 用邻接表实现赋权有向图 用邻接表实现赋权有向图时,每个顶点相应的邻接表中除了存储与该顶点相应的边信息外, 还要存储与边相关联的边权信息。因此,与赋权有向图相应的邻接表结点类型定义如下: 3<’969) 23-7=3 #%469 !"#$%&; 23-7=3 #%469 { ! ! $%3 8;! ! ! ! !!边的另一个顶点 !! ! ! >?39@ :; !!边权 !! ! ! "#$%& %913; !!邻接表指针 !! ! ! }; "#$%& A9:B>A469($%3 8,>?39@ :,"#$%& %913) { ! ! "#$%& 1 "@.##4=(2$C94) !1); ! ! 1 #$8 "8;1 #$: ":;1 #$%913 "%913; ! ! -937-% 1; } !"#第 *D 章! 图 其中,! 是边的另一个顶点;" 是边权;!"#$ 是邻接表指针,指向邻接表的下一个结点。函数 %"& ’()%*+" 创建一个新的邻接表结点。 用邻接表实现赋权有向图的结构定义如下: $,-"+". /$012$ 304-5 !604-5; /$012$ 304-5{ 7 7 8!$ !;7 7 7 7 !!顶点数 !! 7 7 8!$ "; !!边数 !! 7 7 398!: !4+;; !!邻接表数组 !! }(304-5; 其中,数组 4+; 存储赋权有向图的邻接表;# 是赋权有向图的顶点数;$ 是赋权有向图的边数。 函数 604-58!8$(!)创建一个用邻接表表示的有 # 个孤立顶点的赋权有向图。实现方法 如下: 604-5 604-58!8$(8!$ !) { 7 7 8!$ 8; 7 7 604-5 6 "<499*2(/8="*. !6); 7 7 6 #$! "!; 7 7 6 #$" ">; 7 7 6 #$4+; "<499*2((? %@)!/8="*.(398!:)); 7 7 .*0(8 ">;8 &"?;8 %%)6 #$4+;[8]">; 7 7 0"$10! 6; } 函数 604-5A"0$82"/(6)和 604-5B+3"/(6)分别返回赋权有向图 % 的顶点数和边数。实现方 法如下: 8!$ 604-5B+3"/(604-5 6) {0"$10! 6 #$";} 8!$ 604-5A"0$82"/(604-5 6) {0"$10! 6 #$!;} 函数 604-5B#8/$(8,;,6)判断当前赋权有向图 % 中的边(&,’)是否存在。实现方法如下: !"# 数据结构与算法 !"# $%&’()*!+#(!"# !,!"# ,,$%&’( $) { - - ./!"0 ’; - - !1(! &2 ’’ , &2 ’’ ! $$ #$" ’’ , $$ #$")%3#4%" 5; - - ’ "$ #$&6,[!]; - - 7(!/3(’ 88 ’ #$9 !",)’ "’ #$"3*#; - - !1(’)%3#4%" 2; - - 3/+3 %3#4%" 5; } 函数 $%&’(:66(!,,,7,$)通过在顶点 ! 的邻接表的表首插入顶点 " 来实现向赋权有向图中 加入一条边权为 # 的有向边(!,")。实现方法如下: 9;!6 $%&’(:66(!"# !,!"# ,,<=#3> 7,$%&’( $) { - - !1(! &2 ’’ , &2 ’’ ! $$ #$" ’’ , $$ #$" ’’ ! "", ’’ $%&’()*!+#(!,,,$)) - - - - )%%;%((?&6 !"’4#(); - - $ #$&6,[!]"@37A<@;63(,,7,$ #$&6,[!]); - - $ #$3 %%; } 函数 $%&’(B3/3#3(!,,,$)删除赋权有向图 $ 中的边(!,")。实现方法如下: 9;!6 $%&’(B3/3#3(!"# !,!"# ,,$%&’( $) { - - ./!"0 ’,C; - - !1(! &2 ’’ , &2 ’’ ! $$ #$" ’’ , $$ #$" ’’ !$%&’()*!+#(!,,,$)) - - - - )%%;%((?&6 !"’4#(); - - ’ "$ #$&6,[!]; - - !1(’ #$9 "",){$ #$&6,[!]"’ #$"3*#;1%33(’);} - - 3/+3 { - - - 7(!/3(’ 88 ’ #$"3*# #$9 !",)’ "’ #$"3*#; - - - !1(’){C "’ #$"3*#;’ #$"3*# "C #$"3*#;1%33(C);} - - - } - - $ #$3 ##; } 函数 D4#B3.%33(!,$)通过计算顶点 ! 的邻接表长,返回有赋权向图 $ 中顶点 ! 的出度。实 !"#第 2E 章- 图 现方法如下: !"# $%#&’()’’(!"# !,*)+,- *) { . . (/!"0 ,; . . !"# 1 "2; . . !3(! &4 ’’ ! $* #$")5))6)((7+8 !",%#(); . . , "* #$+81[!]; . . 9-!/’(,){1 %%;, ", #$"’:#;} . . )’#%)" 1; } 函数 ;"&’()’’(!,*)返回赋权有向图 ! 中顶点 " 的入度。实现方法如下: !"# ;"&’()’’(!"# !,*)+,- *) { . . !"# 1,<%= "2; . . !3(! &4 ’’ ! $* #$")5))6)((7+8 !",%#(); . . 36)(1 "4;1 &"* #$";1 %%) . . . !3(*)+,-5:!<#(1,!,*))<%= %%; . . )’#%)" <%=; } 函数 *)+,-$%#(*)输出赋权有向图 ! 的邻接表。实现方法如下: >6!8 *)+,-$%#(*)+,- *) { . . (/!"0 ,; . . !"# !; . . 36)(! "4;! &"* #$";! %%){ . . . . , "* #$+81[!]; . . . . 9-!/’(,){?-69@68’(,);, ", #$"’:#;} . . . . ,)!"#3(()"(); . . . . } } !"! 数据结构与算法 !"# $# %& 用邻接表实现赋权无向图 用邻接表实现赋权无向图时,将一个赋权无向图 ! 当作一个赋权有向图来处理,即将赋权 无向图 ! 的每一条权值为 " 的边(#,$),用 ! 条权值为 " 的赋权有向边(#,$)和($,#)来代替。因 此,函数 "#$%&’(((),*,+,")在赋权无向图 ! 中加入权值为 " 的有向边(#,$)时还应同时加入权 值为 " 的有向边($,#)。实现方法如下: ,-)( "#$%&’((()./ ),)./ *,01/23 +,"#$%& ") { 4 4 )5() &6 ’’ * &6 ’’ ) $" #$. ’’ 4 4 4 4 * $" #$. ’’ ) ""* ’’ "#$%&78)9/(),*,")) 4 4 4 4 7##-#((:$( ).%;/(); 4 4 " #$$(*[)]"<2+=0<-(2(*,+," #$$(*[)]);!!加入有向边(),*)!! 4 4 " #$$(*[*]"<2+=0<-(2(),+," #$$(*[*]);!!加入有向边(*,))!! 4 4 " #$2 %%; } 函数 "#$%&>2?2/2(),*,")在图 ! 中删除边(#,$)时还应同时删除边($,#)。实现方法如下: ,-)( "#$%&>2?2/2()./ ),)./ *,"#$%& ") { 4 4 @?).A %,B; 4 4 )5() &6 ’’ * &6 ’’ ) $" #$. ’’ * $" #$. ’’ !"#$%&78)9/(),*,")) 4 4 4 4 7##-#((:$( ).%;/(); 4 4 % "" #$$(*[)];!!删除边(),*)!! 4 4 )5(% #$, ""*){" #$$(*[)]"% #$.28/;5#22(%);} 4 4 2?92 { 4 4 4 +&)?2(% CC % #$.28/ #$, !"*)% "% #$.28/; 4 4 4 )5(%){B "% #$.28/;% #$.28/ "B #$.28/;5#22(B);} 4 4 4 } 4 4 % "" #$$(*[*];!!删除边(*,))!! 4 4 )5(% #$, " ")){" #$$(*[*]"% #$.28/;5#22(%);} 4 4 2?92 { 4 4 4 +&)?2(% CC % #$.28/ #$, !"))% "% #$.28/; 4 4 4 )5(%){B "% #$.28/;% #$.28/ "B #$.28/;5#22(B);} 4 4 4 } 4 4 " #$2 ##; } !"#第 6D 章4 图 !"# $% 图 的 遍 历 许多关于图的算法都需要系统地访问图的每一个顶点。本节所讨论的图的深度优先搜索和 广度优先搜索就是系统地访问图的所有顶点,即遍历一个图的 & 个重要方法。任意给定图的一 个顶点,用这 & 种方法都可以访问到与这个给定顶点有路相连的所有顶点。 !"# $# !% 广度优先搜索 广度优先搜索是系统地访问一个图的所有顶点的方法。其基本思想是,从图中某个顶点 ! 出发,在访问了顶点 ! 之后,接着就尽可能先在横向搜索 ! 的邻接顶点。在依次访问 ! 的各个未 被访问过的邻接顶点之后,分别从这些邻接顶点出发,递归地以广度优先方式搜索图中其他顶 点,直至图中所有已被访问的顶点的邻接顶点都被访问到。若此时图中还有未被访问的顶点,则 再选择一个这样的顶点作为起始顶点,重复上述过程,直至图中所有顶点都被访问到为止。换句 话说,以广度优先搜索策略遍历图的过程是以一个顶点 ! 为起始顶点,由近及远,依次访问和顶 点 ! 有路相通,且路径长度为 !,&,⋯ ,的顶点。 广度优先搜索图的算法 ’() 可描述如下: ’()(*,+) {% !!从顶点 , 开始,广度优先搜索图 * 的算法 !! % % 标记顶点 +; % % 用顶点 + 初始化顶点队列 -; % % ./+01(!-212134567(-)){ % % % + "810161-2121(-); % % % 设 9 是 + 的邻接顶点; % % % ./+01(9){ % % % % +((9 未标记){标记顶点 9;3:61;-2121(9,-);} % % % % 9 "+ 的下一个邻接顶点; % % % % } % % % } } 上述算法适用于前面讨论的各种类型的图。在具体实现时,用一个数组 5;1 来记录搜索到 的顶点的状态。初始时对所有顶点 " 有 5;1[+]< =。用一全局整型变量 >:6 记录算法对图中顶点 的访问次序。算法结束后,数组 5;1[+]中的值是算法访问顶点 ! 的序号。 在用邻接矩阵实现的无向图 # 中的广度优先搜索算法 ’() 可实现如下: !!" 数据结构与算法 !"#$ %&’(()*+, (,#-. #) { / / #-. 0; / / 12323 4 "123235-#.(); / / 6-.3)12323(#,4); / / 7,#83(!1232369+.:(4)) / / / #&(+)3[# ";383.312323(4)] ""<){ / / / / +)3[#]"=-. %%; / / / / &")(0 ">;0 &"( #$-;0 %%) / / / / / #&(( #$*[#][0]) / / / / / / #&(+)3[0] ""<)6-.3)12323(0,4); / / / / } } 上述算法可以遍历图 ! 的顶点 " 所在连通分支中所有顶点。调用函数 %&’ 一次只能遍历图 的一个连通分支。当图 ! 是连通图时,只要调用 > 次 %&’ 即可遍历图 ! 的所有顶点。而当图 ! 有多个连通分支时,必须对每一个连通分支调用 > 次 %&’。 用广度优先搜索方式遍历图 ! 的算法如下: !"#$ ()*+,?3*)=,(()*+, () { / / #-. #; / / =-. ">; / / &")(# ">;# &"( #$-;# %%)+)3[#]"<; / / &")(# ">;# &"( #$-;# %%) / / / #&(+)3[#] ""<)%&’((,#); } 从算法 %&’ 可以看到,每个被访问到的顶点都只进入队列 # 一次。被访问顶点在其邻接矩 阵所在的行,或其邻接表恰被遍历 > 次。如果在 > 次 %&’ 的搜索过程中访问了 $ 个顶点,那么用 邻接 矩 阵 表 示 图 时,所 需 的 搜 索 时 间 为 %( $&);用 邻 接 表 表 示 图 时,所 需 的 搜 索 时 间 为 % #" %’("( ))。 注意到算法 %&’ 中,队列 ( 中可能有重复的顶点。而每个未访问的顶点只需要处理一次。 由此可见队列 ( 中的重复顶点影响了算法的效率,应设法避免。如果在队列的入队运算中,加入 重复顶点判断,并舍去重复顶点,可避免上述问题。解决这个问题的另一个方法是,每访问一个 顶点就立即标记该顶点,同时队列 ( 仅存储新近访问的顶点。这样一来,队列 ( 中的顶点就都是 已访问过的顶点,从而避免了重复顶点。根据上述思想对算法 %&’ 改进如下: !"#第 >@ 章/ 图 !"#$ %&’(()*+, (,#-. #) { / / #-. 0; / / 12323 4 "123235-#.(); / / 6-.3)12323(#,4); / / +)3[#]"7-. %%; / / 8,#93(!123236:+.;(4)){ / / / # "<393.312323(4); / / / &")(0 "=;0 &"( #$-;0 %%) / / / / #&((( #$*[#][0])>>(+)3[0] ""?)) / / / / {6-.3)12323(0,4);+)3[0]"7-. %%;} / / / } } 容易看出,用改进后的算法 %&’ 对有 ! 个顶点的图 " 进行广度优先遍历时,队列 # 中最多只 有 ! 个顶点。由此可知,用邻接矩阵表示图 " 时,广度优先遍历所需的搜索时间为 $(!@ );用邻 接表表示图 " 时,广度优先遍历所需的搜索时间为 $(! % &)。其中 ! 为图 " 的顶点数;& 为图 " 的边数。 !"# $# %& 深度优先搜索 用深度优先搜索策略来遍历一个图类似于树的前序遍历,它是树的前序遍历方法的推广。 深度优先搜索的基本思想是,对于给定的图 " ’((,)),首先将 ( 中每一个顶点都标记为未 被访问。然后,选取一个顶点 *$(,并开始搜索。标记 * 为已访问,再递归地用深度优先搜索方 法,依次搜索 * 的每一个未被访问过的邻接顶点。如果从 * 出发有路可达的顶点都已被访问过, 则从 * 开始的搜索过程结束。此时,如果图中还有未被访问过的顶点,则再任选一个未被访问过 的顶点,并从这个顶点开始做新的搜索。上述过程一直进行到 * 中所有顶点都已被访问过为止。 因为上述搜索方法总是尽可能地先对纵深方向进行搜索,所以称为深度优先搜索。例如,设 + 是刚被访问过的顶点,按深度优先搜索方法,下一步将选择 + 的一个邻接顶点 ,。如果发现 , 已被访问过,就重新选择 + 的另一个邻接顶点。如果 , 未被访问过,则访问顶点 ,,将它标记为已 访问,并进行从 , 点开始的深度优先搜索,直到搜索完从 , 出发的所有路,才退回到顶点 +,再选 择 + 的一个未被访问过的邻接顶点。上述过程一直进行到 + 的所有邻接顶点都被访问过为止。 显然,上述遍历图 " 的顶点的过程是一个递归过程。在具体实现时,用一个数组 +)3 来记录 搜索到的顶点的状态。初始时对所有顶点 * 有 +)3[#]A ?。用一全局整型变量 7-. 记录算法对图 中顶点的访问次序。算法结束后,数组 +)3[#]中的值是算法访问顶点 - 的序号。 在用邻接矩阵实现的无向图 " 中的深度优先搜索算法 $&’ 可实现如下: !"#$ $&’(()*+, (,#-. #) !"# 数据结构与算法 { ! ! "#$ %; ! ! &’(["]")#$ %%; ! ! *+’(% ",;% &"- #$#;% %%) ! ! ! "*(- #$.["][%]) ! ! ! ! "*(&’([%] ""/)0*1(-,%); } 类似地,在用邻接表实现的有向图 ! 中的深度优先搜索算法 0*1 可实现如下: 2+"0 0*1(-’.&3 -,"#$ ") { ! ! 45"#6 &; ! ! &’(["]")#$ %%; ! ! *+’(& "- #$.0%["];&;& "& #$#(7$) ! ! ! "*(&’([& #$2] ""/)0*1(-,& #$2); } 上述算法可以遍历图 ! 的顶点 " 所在连通分支中所有顶点。调用函数 0*1 一次只能遍历图 的一个连通分支。当图 ! 是连通图时,只要调用 , 次 0*1 即可遍历图 ! 的所有顶点。而当图 ! 有多个连通分支时,必须对每一个连通分支调用 , 次 0*1。 用深度优先搜索方式遍历图 ! 的算法如下: 2+"0 -’.&38(.’)3(-’.&3 -) { ! ! "#$ "; ! ! )#$ ",; ! ! *+’(" ",;" &"- #$#;" %%)&’(["]"/; ! ! *+’(" ",;" &"- #$#;" %%) ! ! ! "*(&’(["] ""/)0*1(-,"); } 深度优先搜索算法 0*1 与广度优先搜索算法 9*1 具有相同的计算时间复杂性。用邻接矩阵 表示图 ! 时,深度优先遍历所需的搜索时间为 #($: );用邻接表表示图 ! 时,深度优先遍历所需 的搜索时间为 #($ % &)。其中 $ 为图 ! 的顶点数;& 为图 ! 的边数。 !"#第 ,; 章! 图 !"# $% 最 短 路 径 本节讨论在一个赋权有向图上寻找最短路径的问题。在一般情况下,最短路径问题可分为 单源最短路径和所有顶点对之间的最短路径两大类问题。下面分别进行讨论。 !"# $# !% 单源最短路径 给定一个赋权有向图 ! "(#,$),其中每条边的权是一个非负实数。另外,还给定 # 中的一 个顶点,称为源。现在要计算从源到图 ! 的所有其他各顶点的最短路长度。这里路的长度是指 路上各边权之和。这个问题通常称为单源最短路径问题。 解单源最短路径的一个常用算法是 &’()*+,- 算法。其基本思想是,设置一个顶点集合 % 并 不断地作贪心选择来扩充这个集合。一个顶点属于集合 % 当且仅当从源到该顶点的最短路径长 度已知。初始时,% 中仅含有源。设 & 是 ! 的某一个顶点,把从源到 & 且中间只经过 % 中顶点的 路称为从源到 & 的特殊路径,并用数组 .’*+ 来记录当前每个顶点所对应的最短特殊路径长度。 &’()*+,- 算法每次从 # ’ % 中取出具有最短特殊路长度的顶点 &,将 & 添加到 % 中,同时对数组 .’*+ 作必要的修改。一旦 % 包含了所有 # 中顶点,.’*+ 就记录了从源到所有其他顶点之间的最短 路径长度。 &’()*+,- 算法可描述如下。其中输入的赋权有向图是 ! "(#,$),# "{!,/⋯ ,(}。顶点 ) 是 源。.’*+[0]表示当前从源 ) 到顶点 * 的最短特殊路径长度。1,20[0]表示从源 ) 到顶点 * 的最短 特殊路径上顶点 * 的前驱顶点。 步骤 !:% 初始化 .’*+[0] 3 -[*][0],!"0"4; 对于所有与 * 邻接的顶点 0 置 1,20[0]3 *; 其余顶点 5 置 1,20[5]3 6; 建立表 7 包含所有 1,20[0]!6 的顶点 08 步骤 /: 若表 7 空,则算法结束,否则转步骤 "。 步骤 ": 从表 7 中取出 .’*+ 值最小的顶点 08 步骤 9: 对于顶点 0 的所有邻接顶点 5 置 .’*+[5]3 :’4{.’*+[5],.’*+[0]; -[0][5]}; 若 .’*+[5]改变,则置 1,20[5]3 0,且若 5 不在表 7 中 将 5 加入 7;转步骤 /。 在用邻接矩阵实现的赋权有向图中单源最短路径问题的 &’()*+,- 算法可实现如下: 0<’. &’()*+,-(’4+ *,=>+2: .’*+[],’4+ 1,20[],?,-1@ ?) { !"# 数据结构与算法 ! ! "#$ ",%; ! ! &"’$ & "&"’$(#"$(); ! ! ")(’ &* ’’ ’ $+ #$#),--.-((/0$ .) 1.0#2’(); ! ! !!初始化 2"’$,3-45,和 & !! ! ! ).-(" "*;" &"+ #$#;" %%){ ! ! ! 2"’$["]"+ #$6[’]["]; ! ! ! ")(2"’$["] ""+ #$7.,284)3-45["]"9; ! ! ! 4:’4 {3-45["]"’;&"’$(#’4-$(9,",&);} ! ! ! } ! ! 2"’$[’]"9; ! ! !!修改 2"’$ 和 3-45 !! ! ! ;<":4(!&"’$,=3$>(&)){ ! ! ! !!找 & 中具有最小 2"’$ 值的顶点 5 !! ! ! ! !!将顶点 5 从表 & 中删除,并修改 2"’$ 的值 !! ! ! ! " "&"’$?4:@"#(&,2"’$); ! ! ! ).-(% "*;% &"+ #$#;% %%){ ! ! ! ! ")(+ #$6["][%]!"+ #$7.,284 AA(!3-45[%]’’ ! ! ! ! ! ! ! 2"’$[%] $2"’$["] B + #$6["][%])){ ! ! ! ! ! !!2"’$[%]减少 !! ! ! ! ! ! 2"’$[%]"2"’$["] B + #$6["][%]; ! ! ! ! ! !!顶点 % 插入表 & !! ! ! ! ! ! ")(!3-45[%])&"’$(#’4-$(9,%,&); ! ! ! ! ! 3-45[%]"";} ! ! ! ! } ! ! ! } } 其中,函数 &"’$?4:@"#(&,2"’$)返回表 ! 中具有最小 2"’$ 值的顶点 ",并将顶点 " 从表 ! 中删除。 &"’$($4= &"’$?4:@"#(&"’$ &,C($4= 2"’$[]) { ! ! :"#D 3,E,$,-; ! ! &"’$($4= F; ! ! 3 "& #$)"-’$;$ "3;- "$;E "3 #$#4F$; ! ! ;<":4(E){ ! ! ! ")(2"’$[E #$4:4=4#$]&2"’$[- #$4:4=4#$]){$ "3;- "E;} ! ! ! 3 "E; ! ! ! E "E #$#4F$; !"#第 *G 章! 图 ! ! ! } ! ! "#($ ""%)& #$#"%’$ "% #$()*$; ! ! )+’) $ #$()*$ "% #$()*$; ! ! * "% #$)+),)($; ! ! #%))(%); ! ! %)$-%( *; } 例如,对图 ./0 .. 中的赋权有向图,应用 1"23’$%4 算法计算从源顶点 . 到其他顶点间最短路 径的过程列在表 50 . 中。 图 ./0 ..! 一个赋权有向图 表 !" #$ %&’()*+, 算法的迭代过程 迭! ! 代 6 - 7"’$[8] 7"’$[/] 7"’$[9] 7"’$[:] 初! ! 始 {.} ; .< = /< .<< . {.,8} 8 .< >< /< .<< 8 {.,8,9} 9 .< :< /< ?< / {.,8,9,/} / .< :< /< >< 9 {.,8,9,/,:} : .< :< /< >< 上述 1"23’$%4 算法只求出从源顶点到其他顶点间的最短路径长度。如果还要求出相应的最 短路径,可以用算法中数组 @%)A 记录的信息求出相应的最短路径。算法中数组 @%)A["]记录的 是从源到顶点 ! 的最短路径上 ! 的前一个顶点。初始时,对所有 "!.,置 @%)A["]B A。在 1"23’$%4 算法中更新最短路径长度时,只要 7"’$[-]C D[-]["]&7"’$["]时,就置 @%)A["]B -。当 1"23’$%4 算法终止时,就可以根据数组 @%)A 找到从源到 ! 的最短路径上每个顶点的前一个顶点,从而找到 从源到 ! 的最短路径。 例如,对于图 ./0 .. 中的有向图,经 1"23’$%4 算法计算后可得数组 @%)A 具有值 @%)A[8]B ., @%)A[/]B 9,@%)A[9]B .,@%)A[:]B /。如果要找出顶点 . 到顶点 : 的最短路径,可以从数组 @%)A 得到顶点 : 的前一个顶点是 /,/ 的前一个顶点是 9,9 的前一个顶点是 .。于是从顶点 . 到顶点 : 的最短路径是 .,9,/,:。 下面来讨论 1"23’$%4 算法的正确性和计算复杂性。 !"# 数据结构与算法 !"#$%&’( 算法是应用贪心算法设计策略的一个典型例子。它所作的贪心选择是从 ! " # 中选 图 )*+ ),- 从源到 $ 的最短路径 择具有最短特殊路径的顶点 $,从而确定从源到 $ 的最短路径长 度 ."%&[/]。这种贪心选择为什么能导致最优解呢?换句话说,为 什么从源到 $ 没有更短的其他路径呢?事实上,如果存在一条从 源到 $ 且长度比 ."%&[/]更短的路。设这条路初次走出 # 之外到 达的顶点为 %$! " #,然后徘徊于 # 内外若干次,最后离开 # 到达 $,如图 )*+ ), 所示。 在这条路径上,分别记 &(’,%)、&(%,$)和 &(’,$)为顶点 ’ 到 顶点 %,顶点 % 到顶点 $ 和顶点 ’ 到顶点 $ 的路长,那么,有 ."%&[0]" .(%,0) .(%,0)1 .(0,/)2 .(%,/)3 ."%&[/] 利用边权的非负性,可知 .(0,/)&4,从而推得 ."%&[0]3 ."%&[/]。此为矛盾。这就证明了 ."%&[/]是从源到顶点 / 的最短路径长度。 要完成 !"#$%&’( 算法正确性的证明,还必须证明最优子结构性质,即算法中确定的 ."%&[/]确 实是当前从源到顶点 $ 的最短特殊路径长度。为此,只要考察算法在添加 $ 到 # 中后,."%&[/] 的值所起的变化就行了。将添加 $ 之前的 # 称为老的 #。当添加了 $ 之后,可能出现一条到顶 点 ( 的新的特殊路。如果这条新特殊路是先经过老的 # 到达顶点 $,然后从 $ 经一条边直接到达 顶点 (,则这种路的最短长度是 ."%&[/]1 5[/]["]。这时,如果 ."%&[/]1 5[/]["]3 ."%&["],则算 法中用 ."%&[/]1 5[/]["]作为 ."%&["]的新值。如果这条新特殊路径经过老的 # 到达 $ 后,不是 从 $ 经一条边直接到达 (,而是像图 )*+ )* 那样,回到老的 # 中某个顶点 %,最后才到达顶点 (,那 图 )*+ )*- 非最短的特殊路径 么由于 % 在老的 # 中,因此 % 比 $ 先加入 #,故图 )*+ )* 中 从源到 % 的路的长度比从源到 $,再从 $ 到 % 的路的长度 小。于是当前 ."%&["]的值小于图 )*+ )* 中从源经 % 到 ( 的 路的长度,也小于图中从源经 $ 和 %,最后到达 ( 的路的长 度。因此,在算法中不必考虑这种路。由此即知,不论算法 中 ."%&[/]的值是否有变化,它总是关于当前顶点集 # 的到 顶点 $ 的最短特殊路径长度。!"#$%&’( 算法的计算复杂性: 对于一个具有 ) 个顶点和 * 条边的赋权有向图,如果用赋权邻接矩阵表示这个图,那么 !"#$%&’(算法的主循环体需要 +())时间。这个循环需要执行 ) " ) 次,所以完成循环需要 +(), ) 时间。算法的其余部分所需要时间不超过 +(), )。 !"# $# %& 所有顶点对之间的最短路径 给定一个赋权有向图 , -(!,.),其中每一条边($,/)的权 0[$][/]是一个非负实数。要 求对任意的顶点有序对($,/)找出从顶点 $ 到顶点 / 的最短路径长度。这个问题就称为赋权有 向图的所有顶点对之间的最短路径问题。 解决这个问题的一个办法是,每次以一个顶点为源,重复执行 !"#$%&’( 算法 ) 次。这样,就 可以求得所有顶点对之间的最短路径。容易看出,这样做所需的计算时间为 +()* )。 !"#第 )* 章- 图 下面介绍求所有顶点对之间最短路径的较直接的 !"#$% 算法,其基本思想是: 设 ! " {&,’,⋯ ,# },设置一个 # $ # 矩阵 !,初始时 ([)][*]+ ,[)][*]。 然后,在矩阵 ! 上做 # 次迭代。经第 % 次迭代之后,&[)][*]的值是从顶点 ’ 到顶点 (,且中间 不经过编号大于 % 的顶点的最短路径长度。在 ! 上做第 % 次迭代时,用下面的公式来计算: ([)][*]+ -). {([)][*],([)][/]0 ([/][*]}。 这个公式可以直观地用图 &12 &3 来表示。 图 &12 &34 从顶点 ’ 到 ( 且经过 顶点 % 的最短路径长度 要计算 ([)][*],只要比较当前 ([)][*]与 ([)][/]0 ([/][*] 的大小。当前 ([)][*]的值表示从顶点 ’ 到 (,中间顶点编号不大 于% ) &的最短路径长度;而 ([)][/]0 ([/][*]表示从顶点 ’ 到 %, 再从 % 到 (,且中间不经过编号大于 % 的顶点的最短路径长度。如 果([)][/]0 ([/][*]5 ([)][*]就置 ([)][*]的值为 ([)][/]0 ( [/][*]。 在用邻接矩阵实现的赋权有向图中,求所有顶点对间最短路 径的 !"#$% 算法可实现如下: 6#)% !"#$%(789:- !!(,).9 !!;,9<,=>,;< =) { ).9 ),*,/; 789:- 9&,9’,91; !!初始化 ([)][*]!! ?#>() "&;) &"= #$.;) %%) 4 ?#>(* "&;* &"= #$.;* %%){ 4 4 ([)][*]"= #$,[)][*]; 4 4 ;,9<[)][*]"@; 4 4 } ?#>() "&;) &"= #$.;) %%) 4 ([)][)]"@; !!循环计算 ([)][*]的值 !! ?#>(/ "&;/ &"= #$.;/ %%) 4 ?#>() "&;) &"= #$.;) %%) 4 4 ?#>(* "&;* &"= #$.;* %%){ 4 4 4 4 9& "([)][/]; 4 4 4 4 9’ "([/][*]; 4 4 4 4 91 "([)][*]; 4 4 4 4 )?(9& !"= #$A#B%C: DD 9’ !"= #$A#B%C: DD 4 4 4 4 4 (91 ""= #$A#B%C: ’’ 9& %9’ &91)){ 4 4 4 4 4 4 ([)][*]"9& %9’; 4 4 4 4 4 4 ;,9<[)][*]"/;} !"! 数据结构与算法 ! ! ! ! } } 上述算法中的 " 维数组 #$%& 用于记录最短路径。当 ! 是算法中使 !["][#]取得最小值的整 数时,就置 #$%&[’][(]) *。当 #$%&[’][(]) + 时,表示从顶点 " 到 # 的最短路径就是从 " 到 # 的 边。在计算出 !["][#]的值后,容易由 #$%& 记录的信息,找出相应的最短路径。 上述 ,-./0 算法的 1 重 2.3 循环耗费 $(%1 )时间,其他语句所需时间不超过 $(%1 )。因此, ,-./0 算法所需的计算时间为 $(%1 )。 415 6! 最小支撑树 设 & ’((,))是一个无向连通赋权图。) 中每条边(*,+)的权为 ,[*][+]。如果 & 的一个 子图 &-是一棵包含 & 的所有顶点的树,则称 &-为 & 的支撑树。支撑树上各边权的总和称为该支 撑树的权。在 & 的所有支撑树中,权值最小的支撑树称为 & 的最小支撑树。 最小支撑树在实际中有广泛应用。例如,在设计通信网络时,用图的顶点表示城市,用边 (*,+)的权 ,[*][+]表示建立城市 * 和城市 + 之间的通信线路所需的费用,则最小支撑树就给出 了建立通信网络的最经济的方案。 !"# $# !% 最小支撑树性质 用贪心算法设计策略可以设计出构造最小支撑树的有效算法。本节中要介绍的构造最小支 撑树的 73’8 算法和 93:;*$- 算法都可以看作是应用贪心算法设计策略的典型例子。尽管这 " 个 算法做贪心选择的方式不同,它们都利用了下面的最小支撑树性质: 设 & ’((,))是一个连通赋权图,. 是 ( 的一个真子集。如果(*,+)$),且 *$.,+$( / .,且在所有这样的边中,(*,+)的权 ,[*][+]最小,那么一定存在 & 的一棵最小支撑树,它以(*, +)为其中一条边。这个性质有时也称为 <=> 性质。 <=> 性质可证明如下。 假设 & 的任何一棵最小支撑树都不含边(*,+)。将边(*,+)添加到 & 的一棵最小支撑树 0 上,将产生一个含有边(*,+)的圈,并且在这个圈上有一条不同于(*,+)的边(*-,+-),使得 *-$ .,+-$( / .,如图 415 4? 所示。将边(*-,+-)删去,得到 & 的另一棵支撑树 0-。由于 ,[*][+]", [*-][+-],所以 0-的权值"0 的权值。于是,0-是一棵含有边(*,+)的最小支撑树,这与假设 矛盾。 !"# $# &% ’()* 算法 设 & ’((,))是一个连通赋权图,( ’{4,",⋯ ,%}。构造 & 的一棵最小支撑树的 73’8 算法 的基本思想是:首先置 1 ’{4}。然后,只要 1 是 ( 的真子集,就作如下的贪心选择:选取满足条 !"#第 41 章! 图 图 !"# !$% 含边(!,")的圈 件 #$$,%$& ’ $,且 ([#][%]最小的边,并将顶点 % 添加到 $ 中。这个过程一直进行到 $ ) & 时为 止。在这个过程中选取到的所有边恰好构成 * 的一棵最小支撑树。实现方法如下: &’()(*) { + ",; - "{!}; ./(01(-!"2){ % ((,3)"($- 且 3$2 #- 的最小权边; % + "+’{((,3)}; % - "-’{3}; % } } 算法结束时,+ 中包含 * 的 ,-! 条边。利用最小支撑树性质和数学归纳法容易证明,上述算 法中的边集合 + 始终包含 * 的某棵最小支撑树中的边。因此,在算法结束时,+ 中的所有边构成 * 的一棵最小支撑树。 例如,对于图 !"# !4 中的赋权图,按 &’() 算法选取边的过程如图 !"# !5 所示。 图 !"# !4% 连通赋权图 在上述 &’() 算法中,还应当考虑如何有效地找出满足条件 #$$,%$& ’ $,且权 ([#][%]最小的边(#,%)。实现这个目的的一 个较简单的办法是设置 6 个数组 708919: 和 08.789:。对于每一个 %$& ’ $,708919:[3]是 % 在 $ 中的一个邻接顶点,它与 % 在 $ 中的 其他邻接顶点 . 相比较有 7[3][708919:[3]]";[3][<]。08.789: [3]的值就是 ;[3][708919:[3]]。 在 &’() 算法执行过程中,先找出 & ’ $ 中使 08.789: 值最小 的顶点 %,然后根据数组 708919: 选取边(3,708919:[3]),最后将 % 添加到 $ 中,并对 708919: 和 08.789: 作必要的修改。 在用邻接矩阵实现的赋权无向图 * 中构造一棵最小支撑树的 &’() 算法可实现如下: =8(> &’()(?@:1) !08.789:,(A: !708919:,*’;B/ *) { (A: (,3,<; !"# 数据结构与算法 图 !"# !$% &’() 算法选边过程 *+,-) )(.; (., !/; / ")01123((4 #$. %!)!/(5-26((.,)); 62’(( "!;( &"4 #$.;( %%){ % 12732/,[(]"4 #$0[(][!]; % 312/-/,[(]"!; % /[(]"8; % } /[!]"!; 62’(( "!;( &4 #$.;( %%){ % )(. "4 #$92:;<-; % = "!; % 62’(> "?;> &"4 #$.;> %%) % % (6((12732/,[>]&)(.)@@(!/[>])){ % % % )(. "12732/,[>]; % % % = ">; % % % } % /[=]"!; % 62’(> "?;> &"4 #$.;> %%) % % (6((4 #$0[>][=]&12732/,[>])@@(!/[>])){ % % % 12732/,[>]"4 #$0[>][=]; % % % 312/-/,[>]"=; !!"第 !" 章% 图 ! ! ! } ! ! } } 易知,上述算法 "#$% 所需的计算时间为 !("& )。 !"# $# "% &’()*+, 算法 构造最小支撑树的另一个常用算法是 ’#()*+, 算法。当图的边数为 # 时,’#()*+, 算法所需 的时间是 !(#,-.#)。当 # $ !("& )时,’#()*+, 算法比 "#$% 算法差,但当 # $ !("& )时,’#()*+, 算法却比 "#$% 算法好得多。 给定无向连通赋权图 % $(&,’),& $ {/,&,⋯ ,"}。’#()*+, 算法构造 % 的最小支撑树的 基本思想是,首先将 % 的 " 个顶点看成 " 个孤立的连通分支。将所有的边按权从小到大排序。 然后从第一条边开始,依边权递增的顺序查看每一条边,并按下述方法连接 & 个不同的连通分 支:当查看到第 ( 条边(),*)时,如果端点 ) 和 * 分别是当前 & 个不同的连通分支 +/ 和 +& 中的 顶点时,就用边(),*)将 +/ 和 +& 连接成一个连通分支,然后继续查看第 ( , / 条边;如果端点 ) 和 * 在当前的同一个连通分支中,就直接再查看第 ( , / 条边。这个过程一直进行到只剩下一个 连通分支时为止。此时,这个连通分支就是 % 的一棵最小支撑树。 例如,对图 /01 /2 中的连通赋权图,按 ’#()*+, 算法顺序得到的最小支撑树上的边如图 /01 /3 所示。 图 /01 /3! ’#()*+, 算法选边过程 上述构造最小支撑树的 ’#()*+, 算法需要按权的递增顺序查看图 % 的所有边。为此需要将 % 的所有边按边权值排序。存储每条边的结构定义为: !"# 数据结构与算法 !"#$%$& ’!()*! $%+${,-! );,-! .;/0!$1 2;}3%+$; 函数 3453(),.,2)构造一条权为 2 的边(),.)。实现方法如下: 3%+$ 3453(,-! ),,-! .,/0!$1 2) { 3%+$ $; $6 ) ");$6 . ".;$6 2 "2; ($!)(- $; } 函数 3%+$’(7,5)抽取图 ! 的所有边到赋权边数组 " 中,并返回图 ! 的边数。实现方法 如下: ,-! 3%+$’(3%+$ 7[],5(7#8 5) { ,-! ,,9,: ";; &<((, "=;, &"5 #$-;, %%) > &<((9 ", %=;9 &"5 #$-;9 %%) > > ,&(5 #$7[,][9]!"5 #$?<3%+$) > > > 7[: %%]"3453(,,9,5 #$7[,][9]); ($!)(- :; } 关于集合的一些基本运算可用于实现 @()’:7A 算法。在 @()’:7A 算法中,要对一个由图 ! 的 连通分支组成的集合不断进行修改。将这个由图 ! 的连通分支组成的集合记为 #,则需要用到 的集合的基本运算有: ! BC)-,<-(7,D,B):将 # 中 E 个连通分支 " 和 $ 连接起来,所得的结果称为 % 或 &。 " BC&,-%(.,B):返回 # 中包含顶点 ’ 的连通分支的名字。这个运算用来确定某条边的 E 个端点所属的连通分支。 这些基本运算正是第 =E 章中介绍的抽象数据类型并查集所支持的基本运算。 利用抽象数据类型并查集 BC’$! 可实现 @()’:7A 算法如下: .<,% @()’:7A(3%+$ 1’![],5(7#8 5) { ,-! $,,,:,’,!; 3%+$ 7[17F3]; BC’$! B; !"#第 =G 章> 图 ! ""#$!%(&,’);( ( ( ( !!抽取 ’ 的所有边 !! )*+,-%./0(&,1,!23);( ( !!对边数组 & 排序 !! 4 "45+6+0(’ #$6);( ( ( !!初始化并查集 4 !! 7./(+ "1,- "1;+ &! 88 - &’ #$6 9 3;+ %%){ ( % "457+6#(&[+]: *,4); ( 0 "457+6#(&[+]: ;,4); ( +7(% !"0){( ( ( ( ( !!选取边 &[+]!! ( ( <%0[- %%]"&[+]; ( ( 45*6+.6(%,0,4);( !!合并连通分支 % 和 0 !! ( ( } } } 设输入的连通赋权图有 ! 条边,则将这些边依其权排序的时间为 "(!=.$!)。实现 46+.65+6# 所需的时间为 "(!=.$!)或 "(!=.$!!)。所以 >/*%-&= 算法所需的计算时间为 "(!=.$!)。 3?: @( 图( 匹( 配 设 # $(%,&)是一个无向图。如果顶点集合 % 可分割为 A 个互不相交的子集,并且图中每 条边(’,()所关联的 A 顶点 ’ 和 ( 分属于这 A 个不同的顶点集,则称图 # 为一个二分图。 在学校的教务管理中,排课表是一项例行工作。一般情况下,每位教师可胜任多门课程的教 学,而每个学期只讲授一门所胜任的课程。反之,每学期的一门课程只需一位教师讲授。这就需 要对课程和教师作合理安排。可以用一个二分图来表示教师与课程的这种关系。教师和课程都 是图的顶点,边(),*)表示教师 ) 胜任课程 *。图 3?: 3@ 是表示 B 位教师和 B 门课程之间关系的二 分图。 图 3?: 3@( 二分图及其最大匹配 为每位教师安排一门课程,相当于为每个教师顶点选择一条和课程顶点相关联的边,且任何 A 个教师顶点不和同一课程顶点相关联。这个排课表问题实际上是图的匹配问题。 !"# 数据结构与算法 图匹配问题可描述如下:设 ! "(#,$)是一个图。如果 %($,且 % 中任何 ! 条边都不与 同一个顶点相关联,则称 % 是 ! 的一个匹配。! 的边数最多的匹配称为 ! 的最大匹配。如果图 的一个匹配使得图中每个顶点都是该匹配中某条边的端点,那么就称这个匹配为图的一个完全 匹配。一个图的完全匹配一定是这个图的一个最大匹配。 为了求一个图的最大匹配,可以系统地列举出该图的所有匹配,然后从中选出边数最多者。 这种方法所需要的时间是图中边数的一个指数函数。因此,需要一种更有效的算法。 下面介绍一种利用增广路径求最大匹配的算法。设 % 是图 ! 的一个匹配,将 % 中每边所 关联的顶点称为已匹配顶点,其余顶点称为未匹配顶点。若 & 是图 ! 中一条连通 ! 个未匹配顶 点的路径,并且在路径 & 上属于 % 的边和不属于 % 的边交替出现,则称 & 为一条关于 % 的增广 路径。由此定义可知增广路径具有以下性质: ! 一条关于 % 的增广路径的长度必为奇数,且路上的第一条边和最后一条边都不属于 %。 " 对于一条关于 % 的增广路径 ’,将 % 中属于 ’ 的边删去,将 ’ 中不属于 % 的边添加到 % 中,所得到的边集合记为 %)’,则 %)’ 是一个比 % 更大的匹配。 # % 为 ! 的一个最大匹配当且仅当不存在关于 % 的增广路径。 性质!和"是显而易见的。 对于性质#,当存在一条关于 % 的增广路径时,由性质!可知,% 不是最大匹配。反之,当 % 不是最大匹配时,一定可以找到一条关于 % 的增广路径。事实上,设 ( 是一个比 % 更大的匹 配,并令 !) "(#,%)()。因为 % 和 ( 都是 ! 的一个匹配,所以 # 中的每个顶点最多和 % 中一 条边相关联,也最多和 ( 中一条边相关联,于是 !)的每个连通分支构成一条由 % 和 ( 中的边交 替组成的简单路径或圈。每个圈中所含的 % 和 ( 的边数相同,而每条简单路径是一条关于 % 的增广路径或是一条关于 ( 的增广路径。由于在 !)中属于 ( 的边多于属于 % 的边,所以 !)中 必含关于 % 的增广路径。 由此,求图 ! "(#,$)的最大匹配 % 的算法可描述如下: ! 置 % 为空集。 " 找出一条关于 % 的增广路径 ’,并用 %)’ 代替 %。 # 重复步骤"直至不存在关于 % 的增广路径,最后得到的匹配就是 ! 的一个最大匹配。 在上述算法中,关键的问题是如何根据已有匹配 %,找出 ! 中关于匹配 % 的一条增广路径。 为了简化起见,只讨论 ! 是二分图的情形。 设 ! "(#,$)是一个二分图,% 是 ! 的一个匹配,按下述方法构造一棵树,取 ! 的一个未匹 配顶点作为树根,它位于树的第 " 层。设已经构造好了树的 * + # 层,现在要构造第 * 层。当 * 为 奇数时,将那些关联于第 * + # 层中一个顶点且不属于 % 的边,连同该边关联的另一个顶点一起 添加到树上。当 * 为偶数时,则添加那些关联于第 * + # 层中一个顶点且属于 % 的边,连同该边 关联的另一个顶点。如果在上述构造树的过程中,发现一个未匹配顶点 , 被作为树的奇数层顶 点,则这棵树上从顶点 , 到树根的路径就是一条关于 % 的增广路径;如果在构造树的过程中,既 没有找到增广路径,又无法按要求往树上添加新的顶点,则可以在余下的顶点中再取一个未匹配 顶点作树根,构造一棵新的树。这个过程一直进行下去,如果最终仍未得到任何增广路径,就说 明 % 已经是一个最大匹配了。 例如,图 #$% #&’ 中二分图的一个匹配 % 如图 #$% #&( 所示,其中粗线边表示匹配 % 中的边。 !"#第 #$ 章) 图 按上述方法,取未匹配顶点 !! 作为树根,顶点 "" 是树上第 " 层中惟一的顶点,未匹配边(!!,"") 是树上的一条边。顶点 !# 处于树的第 # 层,匹配边("",!#)是树上的一条边。顶点 "! 是未匹配 顶点,可以添加到第 $ 层。至此找到一条增广路径 %:!!,"",!#,"!。由此增广路径得到图 # 的一 个更大的匹配 $)%,如图 "$& "’( 所示。此时,$)% 是一个完全匹配,从而也是 # 的一个最大 匹配。 设二分图 # 有 & 个顶点和 ’ 条边,$ 是 # 的一个匹配。如果用邻接表表示 #,那么求一条关 于 $ 的增广路径需要 ((’)时间。由于每找出一条新的增广路径都将得到一个更大的匹配,所 以最多求 & ) # 条增广路径就可以得到图 # 的最大匹配。因此,求图 # 的最大匹配所需的计算时 间为 ((&’)。 本 章 小 结 本章讲授实践中常用的表示复杂非线性关系的数据结构图,以及作为抽象数据类型的图的 一般操作和表示图的数据结构。本章着重介绍了图的邻接矩阵表示及其实现方法、图的邻接表 表示及其实现方法以及图的紧缩邻接表表示方法。在此基础上讨论了遍历一个图的 # 个重要方 法,即图的深度优先搜索和广度优先搜索算法。对于实际应用中常遇到的最短路问题,本章详细 叙述了单源最短路径问题的 )*+,-./0 算法和所有顶点对之间最短路径问题的 12345 算法。最小 支撑树问题是另一个经典的图论问题。本章讨论了构造最小支撑树的 %/*6 算法和 7/8-,02 算 法。最后介绍了二分图的概念及其相关的图匹配问题,并讨论了最大匹配问题的增广路径算法。 习9 9 题 !"# !$ 有向图 # *(+,,)的转置是图 #: *(+,,: ),其中 ,: * {(-,.)$ + / +:(.,-)$ ,}。因此,#: 就 是 # 的所有边反向所组成的图。试按邻接表和邻接矩阵 # 种表示法写出从 # 计算 #: 的有效算法,并分析算法 的计算时间复杂性。 !"# %$ 有向图 # *(+,,)的平方图是图 ## *(+,,# ),其中(.,0)$ ,# 当且仅当存在一个顶点 - $ + ,使 得(.,-)$, 且(.,0)$,,即当图 # 中存在一条从顶点 . 到顶点 0 的长度为 # 的路径时,(-,0)$,# 。试按邻接 表和邻接矩阵 # 种表示法分别写出从 # 产生 ## 的有效算法,并分析算法的计算时间复杂性。 !"# "$ 采用邻接矩阵表示一个具有 & 个顶点的图时,大多数关于图的算法时间复杂性为 ((&# ),但也有例 外。例如,即使采用邻接矩阵表示一个有向图 #,确定 # 是否含有一个汇(即入度为 & 1 ",出度为 ; 的顶点),只 需要 ((&)计算时间。试写出其算法。 !"# &$ 设计并实现用紧缩邻接表实现的赋权有向图的结构和算法。 !"# ’$ 对有向图 # 作深度优先搜索,得到一个深度优先生成森林。按树根被访问到的先后顺序将森林中的 树从左到右排列,并从最左边的树开始,顺序对每棵树上的顶点作后序编号,这样可以得到一种顶点的编号。另 外,对 # 作深度优先遍历时,按递归调用 )1< 完成的先后顺序对顶点编号,可以得到另一种顶点编号。证明上 述 # 种对顶点的编号方式得到的顶点编号相同。 !"# ($ 试写一个 ((&)计算时间的算法,用来确定一个具有 & 个顶点的图是否有圈。 !"# )$ 试设计一个算法,用来确定一个无向图的所有连通分支。 !"# *$ 试设计一个函数 1*=5%0.>-,对于给定的图 # 和 # 中的 # 个顶点 - 和 0,输出 # 中从 - 到 0 的所有简 !"# 数据结构与算法 单路径,并分析算法的时间复杂性。 !"# $% 修改无向图的邻接表表示法,使得当 ! 是顶点 " 的邻接表中第一个顶点时,可以在 #(!)时间内删除 边(",!)。试写一个实现这种删除的算法。 !"# !&% 试举例说明如果允许带权有向图中某些边的权为负实数,则 "#$%&’() 算法不能正确地求出从源到所 有其他顶点的最短路径长度。 !"# !!% 设 $ 是一个具有 % 个顶点和 & 条边的带权有向图,各边的权值为 * + ’ ( ! 之间的整数,’ 为一非负 整数。修改 "#$%&’() 算法使其能在 #(’% ) &)时间内计算出从源到所有其他顶点之间的最短路径长度。 !"# !’% 如果允许赋权有向图中某些边的权为负实数,但图中任何一个圈上各边的权之和都是非负实数。 在这种情况下,用 ,-./0 算法能正确求出所有顶点对之间的最短路径长度吗?为什么? !"# !"% 有时仅对赋权有向图上从任意一个顶点到另外任意一个顶点间有没有路感兴趣。修改 ,-./0 算法, 计算出图的道路矩阵 !,使得从顶点 " 到顶点 ! 有路时 !["][!]1 !,否则 !["][!]1 *。 !"# !(% 设 $ *(+,,)是一个赋权有向图,- 是 $ 的一个顶点,- 的偏心距定义为: 2)3 .$+ {从 . 到 - 的最短路径长度 } $ 中偏心距最小的顶点称为 $ 的中心。试利用 ,-./0 算法设计一个求赋权有向图中心的算法。 !"# !)% 试设计一个算法,对于给定的有向图,计算出该有向图中以一个指定顶点为起点的最长简单路,并 分析算法的计算时间复杂性。 !"# !*% 一个 / 维箱(0! ,04 ,⋯ ,0/ )嵌入另一个 / 维箱(1! ,14 ,⋯ ,1/ )是指存在 !,4,⋯ ,/ 的一个排列 !,使 得 0!(!) 2 1! ,0!(4) 2 14 ,⋯ ,0!(/) 2 1/ 。 (!)证明上述箱嵌套关系具有传递性。 (4)试设计一个有效算法,用于确定一个 / 维箱是否可嵌入另一个 / 维箱。 (5)给定由 % 个 / 维箱组成的集合{3! ,34 ,⋯ ,3% },试设计一个有效算法找出这 % 个 / 维箱中的一个最长 嵌套箱序列,并用 % 和 / 来描述算法的计算时间复杂性。 !"# !+% 套汇是指利用货币汇兑率的差异将一个单位的某种货币转换为大于一个单位的同种货币。例如, 假定 ! 美元可以买 *6 7 英镑,! 英镑可以买 86 9 法郎,且 ! 法郎可以买到 *6 !: 美元。通过货币兑换,一个商人可 以从 ! 美元开始买入,得到 *6 7 4 86 9 4 *6 !: * !6 *:; 美元,从而获得 :6 ;< 的利润。 假设已知 % 种货币 5! ,54 ,⋯ ,5% 和有关兑换率的 % 4 % 表 6。其中 6["][!]是一个单位货币 5" 可以买到的货 币 5! 的单位数。 (!)试设计一个有效算法,用以确定是否存在一货币序列 5"! ,5"4 ,⋯ ,5"7 使得 =[#!][#4]! =[#4][#5]! ⋯ ! =[#%][#!]> ! 并分析算法的计算时间; (4)试设计一个算法,打印出满足(!)中条件的所有序列,并分析算法的计算时间。 !"# !,% 试分别设计用广度优先搜索算法 ?,@ 和深度优先搜索算法 ",@ 找出给定图 $ 的一棵支撑树的 算法。 !"# !$% 试设计一个构造图 $ 的支撑树的算法,使构造出的支撑树的边的最大权值达到最小。 !"# ’&% 假设具有 % 个顶点的连通带权图中所有边的权值均为 ! + % 之间的整数,那么能使 A(B&%)- 算法作 何改进,时间复杂性能改进到何程度?若对某常量 ’,所有边的权值均为 ! + ’ 之间的整数,在这种情况下又如 何?在上述 4 种情况下,对 C(#2 算法能作何改进? !"# ’!% 试设计一个算法,判断一个给定的图是否为二分图。 !"# ’’% 找出图 !56 !8) 中无向图的所有最大匹配。 !"# ’"% 写出求一个二分图的最大匹配的算法。 !"# ’(% 证明一个图是二分图当且仅当它不含长度为奇数的圈。举一个非二分图的例子,说明对二分图求 !"#第 !5 章D 图 增广路径的方法不能用于一般图。 !"# $%& 设 ! 和 " 是同一个二分图中的 ! 个匹配,证明 !)" 中至少含有 # ! # $ # " # 个顶点,它们不在任何 一条 ! 的增广路径上。 !"# $’& 对于图 % &(’,(),如果边集合 )(( 使得对于任意的 *$’,都关联于 ) 中的一条边,则称 ) 为图 % 的一个覆盖。图 % 的边数最少的覆盖称为 % 的最小覆盖。 (")给定图 % &(’,()及它的一个最大匹配 !,试设计一个算法求出图 % 的一个最小覆盖; (!)给定图 % &(’,()及它的一个最小覆盖 ),试设计一个算法求 % 的一个最大匹配。 !"! 数据结构与算法 书书书 参 考 文 献 !" #$% #&’()* +,,%$- . /%01(%’2,,)’’()3 4,)2 5&6 4525 72(8128()9 5-* #&:%(;2$<9= #**;9%->?)9> &)3,!@AB C" 75(5 D559)= E%<082)( #&:%(;2$<9:F-2(%*812;%- 2% 4)9;:- 5-* #-5&39;9= #**;9%->?)9&)3= 2$;(* )> *;2;%-= CGG! B" D%-*3 , #,H8(23 I 7 J= K(50$ L$)%(3 M;2$ #00&;152;%-9,#<)(;15- .&9)N;)(= !@OP Q" E%(<)- L /,R);9)(9)- E .,J;N)92 J R= F-2(%*812;%- 2% #&:%(;2$<9:2$) HFL S()99,H1K(5M> /;&&,!@@Q T" /%(%M;2U .,75$-; 7,H)$25 4= V8-*5<)-25&9 %’ 4525 72(8128()9 ;- E W W = X)M Y%(Z:XY,!@@Q P" /%(%M;2U .,75$-; 7,J5[59)Z)(5- 7= E%<082)( #&:%(;2$<9\ E W W = E%<082)( 71;)-1) S()99, !@@P O" 4%-5&* . ]-82$= 7%(2;-: 5-* 7)5(1$;-: N%&8<) B %’ L$) #(2 %’ E%<082)( S(%:(5<<;-:= 9)1%-* )> *;2;%-= #**;9%->?)9&)3,!@@A A" H)$&$%(- ],X5$)( 72= R.4# # S&52’%(< %’ E%<^;-52%(;5& 5-* K)%<)2(;1 E%<082;-:= E5<^(;*:) I-;N)(9;23 S()99,!@@@ @" 7)*:)M;1Z J= #&:%(;2$<9 ;- E:05(2 ! _ Q= #**;9%->?)9&)3,!@@A !G" 7)*:)M;1Z J= #&:%(;2$<9 ;- E:05(2 T= #**;9%->?)9&)3,!@@A !!" L5([5- J%^)(2 .= 4525 72(8128()9 5-* X)2M%(Z #&:%(;2$<9:7%1;)23 ’%( F-*892(;5& 5-* #00&;)* H52$)<52;19,!@AB !C" 严蔚敏,吴伟民 = 数据结构(E 语言版)= 北京:清华大学出版社,!@@O

下载文档,方便阅读与编辑

文档的实际排版效果,会与网站的显示效果略有不同!!

需要 6 金币 [ 分享文档获得金币 ] 3 人已下载

下载文档

相关文档