返回首页返回书籍《Hello 算法》
目录

目录 · CONTENTS

  1. 01序
  2. 02前言
  3. 03关于本书
  4. 04如何使用本书
  5. 05小结
  6. 06初识算法
  7. 07算法无处不在
  8. 08算法是什么
  9. 09小结
  10. 10复杂度分析
  11. 11算法效率评估
  12. 12迭代与递归
  13. 13时间复杂度
  14. 14空间复杂度
  15. 15小结
  16. 16练习
  17. 17数据结构
  18. 18数据结构分类
  19. 19基本数据类型
  20. 20数字编码
  21. 21字符编码
  22. 22小结
  23. 23练习
  24. 24数组与链表
  25. 25数组
  26. 26链表
  27. 27列表
  28. 28内存与缓存
  29. 29小结
  30. 30练习
  31. 31栈与队列
  32. 32栈
  33. 33队列
  34. 34双向队列
  35. 35小结
  36. 36练习
  37. 37哈希表
  38. 38哈希表
  39. 39哈希冲突
  40. 40哈希算法
  41. 41小结
  42. 42练习
  43. 43树
  44. 44二叉树
  45. 45二叉树遍历
  46. 46二叉树数组表示
  47. 47二叉搜索树
  48. 48AVL 树
  49. 49小结
  50. 50练习
  51. 51堆
  52. 52堆
  53. 53建堆操作
  54. 54Top-k 问题
  55. 55小结
  56. 56练习
  57. 57图
  58. 58图
  59. 59图的基础操作
  60. 60图的遍历
  61. 61小结
  62. 62练习
  63. 63搜索
  64. 64二分查找
  65. 65二分查找插入点
  66. 66二分查找边界
  67. 67哈希优化策略
  68. 68重识搜索算法
  69. 69小结
  70. 70练习
  71. 71排序
  72. 72排序算法
  73. 73选择排序
  74. 74冒泡排序
  75. 75插入排序
  76. 76快速排序
  77. 77归并排序
  78. 78堆排序
  79. 79桶排序
  80. 80计数排序
  81. 81基数排序
  82. 82小结
  83. 83练习
  84. 84分治
  85. 85分治算法
  86. 86分治搜索策略
  87. 87构建二叉树问题
  88. 88汉诺塔问题
  89. 89小结
  90. 90练习
  91. 91回溯
  92. 92回溯算法
  93. 93全排列问题
  94. 94子集和问题
  95. 95n 皇后问题
  96. 96小结
  97. 97练习
  98. 98动态规划
  99. 99初探动态规划
  100. 100动态规划问题特性
  101. 101动态规划解题思路
  102. 1020-1 背包问题
  103. 103完全背包问题
  104. 104编辑距离问题
  105. 105小结
  106. 106练习
  107. 107贪心
  108. 108贪心算法
  109. 109分数背包问题
  110. 110最大容量问题
  111. 111最大切分乘积问题
  112. 112小结
  113. 113练习
  114. 114附录
  115. 115编程环境安装
  116. 116一起参与创作
  117. 117术语表
  118. 118参考文献
  119. 119纸质书
第 88 / 119 节

第 88 节 · SECTION 88

汉诺塔问题

在归并排序和构建二叉树中,我们都是将原问题分解为两个规模为原问题一半的子问题。然而对于汉诺塔问题,我们采用不同的分解策略。

问题

给定三根柱子,记为 A、B 和 C 。起始状态下,柱子 A 上套着 $n$ 个圆盘,它们从上到下按照从小到大的顺序排列。我们的任务是要把这 $n$ 个圆盘移到柱子 C 上,并保持它们的原有顺序不变(如下图所示)。在移动圆盘的过程中,需要遵守以下规则。

  1. 圆盘只能从一根柱子顶部拿出,从另一根柱子顶部放入。
  2. 每次只能移动一个圆盘。
  3. 小圆盘必须时刻位于大圆盘之上。

汉诺塔问题示例

我们将规模为 $i$ 的汉诺塔问题记作 $f(i)$ 。例如 $f(3)$ 代表将 $3$ 个圆盘从 A 移动至 C 的汉诺塔问题。

考虑基本情况

如下图所示,对于问题 $f(1)$ ,即当只有一个圆盘时,我们将它直接从 A 移动至 C 即可。

<1>

规模为 1 的问题的解

<2>

hanota_f1_step2

如下图所示,对于问题 $f(2)$ ,即当有两个圆盘时,由于要时刻满足小圆盘在大圆盘之上,因此需要借助 B 来完成移动。

  1. 先将上面的小圆盘从 A 移至 B 。
  2. 再将大圆盘从 A 移至 C 。
  3. 最后将小圆盘从 B 移至 C 。

<1>

规模为 2 的问题的解

<2>

hanota_f2_step2

<3>

hanota_f2_step3

<4>

hanota_f2_step4

解决问题 $f(2)$ 的过程可总结为:将两个圆盘借助 B 从 A 移至 C 。其中,C 称为目标柱、B 称为缓冲柱。

子问题分解

对于问题 $f(3)$ ,即当有三个圆盘时,情况变得稍微复杂了一些。

因为已知 $f(1)$ 和 $f(2)$ 的解,所以我们可从分治角度思考,将 A 顶部的两个圆盘看作一个整体,执行下图所示的步骤。这样三个圆盘就被顺利地从 A 移至 C 了。

  1. 令 B 为目标柱、C 为缓冲柱,将两个圆盘从 A 移至 B 。
  2. 将 A 中剩余的一个圆盘从 A 直接移动至 C 。
  3. 令 C 为目标柱、A 为缓冲柱,将两个圆盘从 B 移至 C 。

<1>

规模为 3 的问题的解

<2>

hanota_f3_step2

<3>

hanota_f3_step3

<4>

hanota_f3_step4

从本质上看,我们将问题 $f(3)$ 划分为两个子问题 $f(2)$ 和一个子问题 $f(1)$ 。按顺序解决这三个子问题之后,原问题随之得到解决。这说明子问题是独立的,而且解可以合并。

至此,我们可总结出下图所示的解决汉诺塔问题的分治策略:将原问题 $f(n)$ 划分为两个子问题 $f(n-1)$ 和一个子问题 $f(1)$ ,并按照以下顺序解决这三个子问题。

  1. 将 $n-1$ 个圆盘借助 C 从 A 移至 B 。
  2. 将剩余 $1$ 个圆盘从 A 直接移至 C 。
  3. 将 $n-1$ 个圆盘借助 A 从 B 移至 C 。

对于这两个子问题 $f(n-1)$ ,可以通过相同的方式进行递归划分,直至达到最小子问题 $f(1)$ 。而 $f(1)$ 的解是已知的,只需一次移动操作即可。

解决汉诺塔问题的分治策略

代码实现

在代码中,我们声明一个递归函数 dfs(i, src, buf, tar) ,它的作用是将柱 src 顶部的 $i$ 个圆盘借助缓冲柱 buf 移动至目标柱 tar :

[file]{hanota}-[class]{}-[func]{solve_hanota}

如下图所示,汉诺塔问题形成一棵高度为 $n$ 的递归树,每个节点代表一个子问题,对应一个开启的 dfs() 函数,因此时间复杂度为 $O(2^n)$ ,空间复杂度为 $O(n)$ 。

汉诺塔问题的递归树

!!! quote

汉诺塔问题源自一个古老的传说。在古印度的一个寺庙里,僧侣们有三根高大的钻石柱子,以及 $64$ 个大小不一的金圆盘。僧侣们不断地移动圆盘,他们相信在最后一个圆盘被正确放置的那一刻,这个世界就会结束。

然而,即使僧侣们每秒钟移动一次,总共需要大约 $2^{64} \approx 1.84×10^{19}$ 秒,合约 $5850$ 亿年,远远超过了现在对宇宙年龄的估计。所以,倘若这个传说是真的,我们应该不需要担心世界末日的到来。