【第一部分:基本递归式】
// 一般定义: 程序调用自身的编程技巧称为递归(recursion),数学上与之对应的是数学归纳法。
// 递归做为一种算法设计技巧,是指函数/过程/子程序在运行过程中直接或间接调用自身而产生的重入现象。
// 递归算法一般用于解决三类问题:
// (1)数据的定义是按递归定义的。(Fibonacci函数)
// (2)问题解法按递归算法实现。(回溯)
// (3)数据的结构形式是按递归定义的。(树的遍历,图的搜索)
// 递归的缺点:
// 递归算法解题相对常用的算法如普通循环等,运行效率较低。
// 因此,应该尽量避免使用递归,除非没有更好的算法或者某种特定情况,递归更为适合的时候。
// 在递归调用的过程当中系统为每一层的返回点、局部量等开辟了栈来存储。递归次数过多容易造成栈溢出等。
// 不过在高级递归式中,为了在递归调用期间共享数据,通常将其声明为static静态类型,既能共享数据,又节约了栈资源。
// 递归式的组成:
// (1) 用于解决子问题的递归调用(前驱/后继;一元/二元/多元);
// (2) 附加操作(附加操作 + 递归调用/子问题的解 = 整体问题的解)
// (3) 递归出口/终结情形
// 只有递归终结情形的解是明确且易解的,其他子问题或整体解都需要根据它来间接合成
// 任何规模的整体解或子问题的解都可以通过分解自身变成更小的子问题从而推进到终结情形
// 换句话说,任何规模的问题通过递归向下必须收敛于终结情形,此时递归深度将达到最大值
// 由终结情形出发(最特殊的子问题)通过不断与附加操作进行合成可以得到任何规模的子问题或整体解
// 递归式的分类:
// 按子问题的拆分个数分为:一元递归式(数组, 链表等);二元递归式(二叉树);多元递归式(图)。
// 按递归调用时机分为:前驱式递归(递归调用在前, 附加操作在后);后继式递归(附加操作在前, 递归调用在后)。
// 一元递归式:常见于对数组,字符串,链表等的处理,它在分解问题时,子问题与问题本身的规模是一样的,仅仅递减一个单位
// 问题的规模正是由于这种细粒度的递减,由量变到质变,最终将问题分解推进到终结情形。
// 一元递归式的实现体内,通常仅存在一次递归调用,其调用路径刚好形成一个线性序列。
// 一元递归式的递归深度与问题的规模成正比,通常在问题规模较大时,一元递归式容易产生栈溢出。
// 二元递归式:常见于对二叉树的处理,以及基于拆半/二分原理的处理(典型的二分查找,归并/快速排序)。
// 它在分解问题时,通常产生两个同等或近似规模的子问题,从而使原问题的规模减半
// 二元递归式的实现体内,通常存在两次递归调用(相互独立或者互斥),其调用路径刚好形成一颗二叉树。
// 二元递归式的递归深度与问题的规模成对数关系,但对于二叉树而言要求其必须是平衡的。
// 多元递归式:常见于对图的处理,以及分步枚举解空间时的处理(参见高级递归式)
// 多元递归式的实现体内,通常存在多次递归调用(例如处在一个循环内),其调用路径刚好形成一颗M叉树(M是递归的广度)。
// 多元递归式的递归深度与图的跨度/步骤数成正比,递归的广度与图的邻接点数/每一部的候选数成正比
// 前驱式递归:指递归调用在附加操作之前完成,有时候这是可选的,有时候这是必须的,考虑以下情形:
// 附加操作是基于子问题的解之上的(归并排序),又或者附加操作会破坏递归调用所依赖的数据关系(反转链表)
// 前驱式递归还存在一个很重要的特性:逆转附加操作(比较典型的例子:逆向输出单链表)。
// 后继式递归:指递归调用在附加操作之后完成,有时候这是可选的,有时候这是必须的,考虑以下情形:
// 附加操作 与 子问题的解 合成 整体问题的解时,附加操作必须在前(典型的例子:快速排序)
// 或者,子问题的解(递归调用)依赖于附加操作。此外,后继式递归不存在逆转附加操作特性。
// 编写递归时一个非常重要的技巧就是要善于利用子递归直接求解子问题
// 一般定义: 程序调用自身的编程技巧称为递归(recursion),数学上与之对应的是数学归纳法。
// 递归做为一种算法设计技巧,是指函数/过程/子程序在运行过程中直接或间接调用自身而产生的重入现象。
// 递归算法一般用于解决三类问题:
// (1)数据的定义是按递归定义的。(Fibonacci函数)
// (2)问题解法按递归算法实现。(回溯)
// (3)数据的结构形式是按递归定义的。(树的遍历,图的搜索)
// 递归的缺点:
// 递归算法解题相对常用的算法如普通循环等,运行效率较低。
// 因此,应该尽量避免使用递归,除非没有更好的算法或者某种特定情况,递归更为适合的时候。
// 在递归调用的过程当中系统为每一层的返回点、局部量等开辟了栈来存储。递归次数过多容易造成栈溢出等。
// 不过在高级递归式中,为了在递归调用期间共享数据,通常将其声明为static静态类型,既能共享数据,又节约了栈资源。
// 递归式的组成:
// (1) 用于解决子问题的递归调用(前驱/后继;一元/二元/多元);
// (2) 附加操作(附加操作 + 递归调用/子问题的解 = 整体问题的解)
// (3) 递归出口/终结情形
// 只有递归终结情形的解是明确且易解的,其他子问题或整体解都需要根据它来间接合成
// 任何规模的整体解或子问题的解都可以通过分解自身变成更小的子问题从而推进到终结情形
// 换句话说,任何规模的问题通过递归向下必须收敛于终结情形,此时递归深度将达到最大值
// 由终结情形出发(最特殊的子问题)通过不断与附加操作进行合成可以得到任何规模的子问题或整体解
// 递归式的分类:
// 按子问题的拆分个数分为:一元递归式(数组, 链表等);二元递归式(二叉树);多元递归式(图)。
// 按递归调用时机分为:前驱式递归(递归调用在前, 附加操作在后);后继式递归(附加操作在前, 递归调用在后)。
// 一元递归式:常见于对数组,字符串,链表等的处理,它在分解问题时,子问题与问题本身的规模是一样的,仅仅递减一个单位
// 问题的规模正是由于这种细粒度的递减,由量变到质变,最终将问题分解推进到终结情形。
// 一元递归式的实现体内,通常仅存在一次递归调用,其调用路径刚好形成一个线性序列。
// 一元递归式的递归深度与问题的规模成正比,通常在问题规模较大时,一元递归式容易产生栈溢出。
// 二元递归式:常见于对二叉树的处理,以及基于拆半/二分原理的处理(典型的二分查找,归并/快速排序)。
// 它在分解问题时,通常产生两个同等或近似规模的子问题,从而使原问题的规模减半
// 二元递归式的实现体内,通常存在两次递归调用(相互独立或者互斥),其调用路径刚好形成一颗二叉树。
// 二元递归式的递归深度与问题的规模成对数关系,但对于二叉树而言要求其必须是平衡的。
// 多元递归式:常见于对图的处理,以及分步枚举解空间时的处理(参见高级递归式)
// 多元递归式的实现体内,通常存在多次递归调用(例如处在一个循环内),其调用路径刚好形成一颗M叉树(M是递归的广度)。
// 多元递归式的递归深度与图的跨度/步骤数成正比,递归的广度与图的邻接点数/每一部的候选数成正比
// 前驱式递归:指递归调用在附加操作之前完成,有时候这是可选的,有时候这是必须的,考虑以下情形:
// 附加操作是基于子问题的解之上的(归并排序),又或者附加操作会破坏递归调用所依赖的数据关系(反转链表)
// 前驱式递归还存在一个很重要的特性:逆转附加操作(比较典型的例子:逆向输出单链表)。
// 后继式递归:指递归调用在附加操作之后完成,有时候这是可选的,有时候这是必须的,考虑以下情形:
// 附加操作 与 子问题的解 合成 整体问题的解时,附加操作必须在前(典型的例子:快速排序)
// 或者,子问题的解(递归调用)依赖于附加操作。此外,后继式递归不存在逆转附加操作特性。
// 编写递归时一个非常重要的技巧就是要善于利用子递归直接求解子问题
// 只所以可以这么做,则是由正确的终结情形和附加操作保证的。
// 【example】
// 反转链表(一元前驱递归式)
ListNode * ReverseList(ListNode * head)
{
if (!head || !head->m_next) //递归终结情形
{
return head;
}
//直接利用子递归反转子链表
ListNode * newHead = ReverseList(head->m_next); //前驱式递归
//附加操作
head->m_next->m_next = head; //这条语句要理解左值与右值的概念
head->m_next = NULL;
return newHead;
}
// 查找单向链表倒数第N个节点(一元前驱递归式)
// 充分利用了前驱式递归逆转附加操作的特性,从而到达了从后向前计数的目的
const ListNode * FindLastOfNthNode(const ListNode * head, int & Nth)
{
if (!head) return NULL; //递归终结情形
const ListNode * node = FindLastOfNthNode(head->m_next, Nth); //前驱式递归
return (--Nth == 0) ? head : node;
}
// 反向拷贝字符串(一元前驱递归式)
// 这是一个著名的面试题,要求不许使用库函数和临时变量
void ReverselyCopyString(const char * src, char dest[])
{
if (!src || !dest) return; //调用参数异常
if (src[0] == '\0') //递归终结情形
{
dest[0] = '\0';
return;
}
ReverselyCopyString(src+1, dest); //前驱式递归
//附加操作
while (*dest != '\0') dest++; //定位至dest字符串末尾
dest[0] = src[0];
dest[1] = '\0';
}
// 最后一个例子,讲述一个典型的二元递归式(二叉树)
// 1. 深入理解前驱式递归与后继式递归的差异
// 2. 深入理解递归式的额外参数的设计方法
// 3. 深入理解递归式:do-something-for-each-element/node
template<typename T>
struct BiTreeNode
{
//基础域
T m_data;
BiTreeNode * m_lchild;
BiTreeNode * m_rchild;
//附加域
int m_span; //当前节点到根节点的跳数
int m_count; //以当前节点作为根的子树节点数
};
// 如何才能刷新一颗二叉树的每一个节点中的附加域字段呢?
template<typename T>
void RefreshBiTreeNodeData(BiTreeNode<T> * root, BiTreeNode<T> * parent /* = NULL*/)
{
if (!root) return; //递归终结情形
//附加操作
//刷新附加域[到根节点跳数] 为什么要位于递归调用前面(后继式递归)?
if (!parent) //根节点
{
root->m_span = 0;
}
else
{
root->m_span = parent->m_span + 1;
}
RefreshBiTreeNodeData(root->m_lchild, root); //递归调用进入左子树
RefreshBiTreeNodeData(root->m_rchild, root); //递归调用进入右子树
//附加操作
//刷新附加域[子树节点数] 为什么要位于递归调用后面(前驱式递归)?
root->m_count = 1; //初始化为1, 指代自己
if (root->m_lchild) //存在左子树
{
root->m_count += root->m_lchild->m_count; //累计左子树节点
}
if (root->m_rchild) //存在右子树
{
root->m_count += root->m_rchild->m_count; //累计右子树节点
}
}
// 思考:如何求得一颗二叉树中距离/跨度最远的两个节点?
【第二部分:高级递归式】
/*
** 这类递归式的典型的特征就是:分步、枚举,组合解。
**
** 递归式高级编程:比较典型的是排列组合问题,这类递归函数的设计与一般递归式不一样,具体表现在
** 首先是递归函数参数的设计,其次这类递归函数的输出结果复杂而繁多,无法通过返回值或输出参数向
** 外层反馈,因此最好的方式是直接向终端输出,关键的问题是整体的结果需要组合子问题的结果,但是
** 一个完整的解/结果却是要等到递归终止(最小子问题)时方能输出,因此在递归下降过程中,需要 通过
** 一个类似栈的数据结构保存中间结果,这个数据结构的存在形式可以是参数,也可是函数内的静态变量
** 甚至可以是一个全局变量。关键是该变量在跨越递归调用时,它的数据状态必须具备持续性。
*/
// 【方格路径问题】
// 在一个N*N的方格中,从起点(0,0)到达终点(n,n)共有多少条路径?
// 每一步只能向右或向下行进一个单位的距离。
// 如下图所示的这个8*8的方格中,共有多少条可行的路径?
(0,0) 0 1 2 3 4 5 6 7 8
0 + + + + + + + + +
1 + + + + + + + + +
2 + + + + + + + + +
3 + + + + + + + + +
4 + + + + + + + + +
5 + + + + + + + + +
6 + + + + + + + + +
7 + + + + + + + + +
8 + + + + + + + + + (8,8)
// 方法一:逆向分解法
// 到达任意一点的路径数等于到达其左侧点和上方点之和
// 另外一个跳台阶的问题也可以利用这种逆向分节子问题的方式求解
// 原题义如下:一个台阶共育n级,如果一次可以跳1级,也可以跳2级,则总共有多少种跳法?
unsigned long enum_path(int row, int col)
{
if (row < 0 || col < 0) return 0; //非法参数的异常情况
if (row == 0 || col == 0) return 1; //递归终结情况
return enum_path(row-1, col) + enum_path(row, col-1); //递归调用
}
// 包裹器
inline unsigned long enum_path_wrapper(int range)
{
return enum_path(range, range);
}
// 方法二:正向分步选取法,组合法
// 通过在每一列选取一个单位行,每一列的单位行又受制于前一列所选取的单位行
// 直到最后一列的单位行成功选取为止,一条可行的路径诞生
// 该方法是对递归式的更高级应用,包括对递归原型的设计
// 如何做到只枚举一条路径?<参考八皇后问题>
void enum_path(int col, int prev_row, int range, unsigned long & count)
{
//静态变量保证了递归调用过程中数据的持续性
static std::vector<int> vRows;
//递归终止条件,每一列都选取了单位行,一条新的路径已经诞生
if (col == range)
{
count++;
return;
}
//为当前列选取一个单位行
for (int i=prev_row; i<=range; i++)
{
vRows.push_back(i); //为当前列选取第i个单位行
enum_path(col+1, i, range, count); //递归调用推进到第col+1列
vRows.pop_back(); //为当前列选取一个不同的单位行,由于互斥,则需要还原
}
}
// 包裹器
unsigned long enum_path_wrapper(int range)
{
unsigned long count = 0;
enum_path(0, 0, range, count);
return count;
}
// 【八皇后问题】
// 枚举所有可行的八皇后摆放情形,或者找出一种可行的摆放情形
// 要求满足每一个皇后都不能两两攻击。
// 该递归式设计的高明之处在于其布尔返回值的运用,实现了枚举所有和枚举一种的可能性
// 此外,由于皇后的不能攻击性,不是每一次递归都能推进到第八列,但这跟返回值无关
// 思考:枚举所有可行解;枚举任一可行解;枚举最优可行解(如何度量最优).
bool inner_octad_queens(int col, bool bEnumAll, int & count)
{
//静态变量保证了递归调用过程中数据的持续性
static std::vector<int> vCol2Row;
//递归终止条件,八个皇后都已放置就绪
if (col == MACRO_EIGHT)
{
count++;
//print_octad_queens(vCol2Row);
if (bEnumAll) // 枚举所有,就必须要尝试所有可能的位置
{
return false;
}
else // 枚举一个可行解,则直接返回true
{
return true;
}
}
//为第col列选取一个可行的位置放置皇后
for (int i=0; i<MACRO_EIGHT; i++)
{
//检查冲突
vCol2Row.push_back(i); //vCol2Row[col] = i;
if (!IsConflict(vCol2Row)) //IsConflict的实现略
{
if (inner_eight_queens(col+1, bEnumAll, count))
{
return true;
}
}
vCol2Row.pop_back(); // 还原
}
return false;
}
int octad_queens_wrapper(bool bEnumAll)
{
int count = 0;
inner_octad_queens(0, bEnumAll, count);
return count;
}
// 【定和数值组合问题】
// 设有两个整数N和Sum,试从数列1, 2, 3, ... , N 中 随意取几个数,
// 使其和恰好等于Sum, 要求枚举出其中所有满足条件的可能组合.
// 注意递归式中参数top的设计
void enum_sum_sequence(int N, int sum, int top)
{
#define MAX_LEN 1024
static int seq[MAX_LEN];
if (sum == 0) // 递归终结条件
{
//print_sequence(seq, top);
return;
}
if (N <= 0) // sum > 0; 这种情况下得不到正解,原因是大数值被过度舍弃了
{
return; //此时的序列不满足条件
}
if (sum >= N) // 这种情况下,N有参选与不参选两种情况
{
seq[top] = N; //参选
enum_sum_sequence(N-1, sum-N, top+1); //N参选的情况下,递归调用
//为什么没有序列的还原动作?思考下第三个参数的用途
enum_sum_sequence(N-1, sum, top); //N不参选的情况下,递归调用
}
else // sum < N; 此时N不可能参选
{
enum_sum_sequence(sum, sum, top); //第一个参数为什么不是N-1,而是sum
}
}
// 【数值排列问题】
// 设有2*N个数值,将他们排成数目相等的两行,
// 要求1:每一行上的数值必须是由大到小排列;
// 要求2:第二行上的每一个数值均大于第一行上对应列的数值。
// 编程求解有多少种这样的排列方式?
// 设有10个数值,将其排成数目相等的两行,按照如下规则对每个元素进行编址:
idxcol 0 1 2 3 4 // 列索引从0开始;
idxaddr1 1 - 2 - 3 - 4 - 5 // idxaddr1 = idxcol + 1;
idxaddr2 6 - 7 - 8 - 9 - 10 // idxaddr2 = idxcol + 1 + columns;
template<typename T>
void enum_ordered_bisequences(const std::vector<T>& vOrderedSeq, int col, int columns, int & count)
{
assert(vOrderedSeq.size() >= 2*columns);
static std::vector<int> vIndex(2*columns, 0);
if (col == columns) //递归终结条件(col从0开始)
{
//print_ordered_bisequences(vOrderedSeq, vIndex);
count++;
return;
}
//从有序序列(从小到大)中选择一个尚未安排的最大数值
int i = 2*columns - 1;
while (i >= 0 && vIndex[i] != 0) i--;
if (i >= 0) //i索引有效,此时vOrderedSeq[i]必是尚未安排的最大数值
{
vIndex[i] = (col + 1) + columns; //将该数值安排在第col列,第2行
//接下来安排第col列,第1行的数值
for (int j=i-1; j>=columns-col-1 /*j>=0*/; j--)
{
if (vIndex[j] == 0) // 尚未安排的元素
{
//该元素可行的前提是:必须要不大于同行的前驱元素
//首先寻找出同行前驱元素(其实该元素也可以通过参数传进来)
int idxaddr = (col-1) + 1; //前驱元素的编址索引
int idxseq = 2*columns - 1;
while (idxseq >= 0 && vIndex[idxseq] != idxaddr) idxseq--; //根据编址索引查找序列索引
//if (idxseq == 0) return; //???
//如果是第0列,或者同行前驱元素不小于该元素,则可行
if (col == 0 || vOrderedSeq[idxseq] >= vOrderedSeq[j])
{
vIndex[j] = (col + 1); //将该数值安排在第col列,第1行
//开始进行递归调用
enum_ordered_bisequences(vOrderedSeq, col+1, columns, count);
vIndex[j] = 0; //尝试其他数值,需要事先还原
}
}
}
vIndex[i] = 0; //还原第二行元素
}
}
template<typename T>
int enum_ordered_bisequences_wrapper(std::vector<T>& vOrderedSeq)
{
int count = 0;
std::sort(vOrderedSeq.begin(), vOrderedSeq.end());
enum_ordered_bisequences(vOrderedSeq, 0, vOrderedSeq.size()/2, count);
return count;
}
// 【字符排列问题】
// 字符串的全排列(字符串偏排列怎么实现)
// 字符串的全排列是偏排列的特殊情形
// 字符串的全排列问题与上面的递归式有何不同? 很明显缺少了持续性变量。
void chars_permutation(char * chars, int index, int len, unsigned long & count)
{
if (index == len)
{
//print_characters(chars, len);
count++;
return
}
for (int i=index; i<len; i++)
{
//为避免生成重复排列,当不同位置的字符相同时不再交换
if(i != index && chars[i] == chars[index]) continue;
std::swap(chars[i], chars[index]); //就地全排列
chars_permutation(chars, index+1, len, count); //开始递归调用
std::swap(chars[i], chars[index]); //尝试下一个值之前需要还原
}
}
unsigned long chars_permutation_wrapper(char * chars)
{
unsigned long count = 0;
chars_permutation(chars, 0, strlen(chars), count);
return count;
}
// 【字符组合问题】
// 字符串的偏组合,即从N个字符中选择M个字符进行组合
// 字符串全组合肯定只用一种,是偏组合的特殊退化情形
void chars_combination(const char * chars, int num, int len, unsigned long & count)
{
static std::vector<char> vCombStr;
if (num == 0) //递归终结条件
{
//print_chars_combination(vCombStr);
count++;
return;
}
if (num > len) //如果组合基数大于字符个数
{
return; //无法产生符合条件的组合数
}
//num <= len
//考虑最后一个字符元素的取舍
vCombStr.push_back(chars[len-1]); //最后一个元素参与组合
chars_combination(chars, num-1, len-1, count); //递归调用
vCombStr.pop_back(); //还原组合序列,表明最后一个元素不参与组合
chars_combination(chars, num, len-1, count); //递归调用
}
unsigned long chars_combination_wrapper(char * chars)
{
unsigned long count = 0;
int len = strlen(chars);
for (int i=1; i<=len; i++)
{
chars_combination(chars, i, len, count);
}
return count;
}
// 思考:如何枚举出一颗二叉树所有的从根节点到各叶子节点的路径?
- - end - -