递归算法这样介绍,想不通过面试都难

I. 什么是递归算法

A. 定义

递归算法是指在函数或过程的定义中使用自身的方法。在递归过程中,一个问题被分解为一个或多个相同或类似的子问题,并且这些子问题会被递归地求解,直到达到最基本的子问题,再利用已知的结果合并解决整个问题。

B. 特点

递归算法具有简洁、优美的代码实现方式,可以将问题分解为相同或类似的子问题进行求解,适用于一些具有递归结构的问题。同时,递归算法的实现也需要考虑好递归的终止条件,否则可能会陷入无限递归的情况。

C. 递归与循环的区别

递归与循环都可以用来解决重复性的问题,但递归与循环的实现方式不同。递归是函数或过程自己调用自己,需要考虑递归终止条件,而循环则是通过控制流程语句实现的,一般情况下,循环的实现效率更高,但递归更易于理解和实现。

II. 递归算法的基本思路

A. 递归的定义和实现

递归的定义是指在函数或过程的定义中使用自身的方法。递归的实现方式是在函数或过程内部调用自身。

B. 递归的三个要素

递归算法的三个要素包括递归终止条件、递归调用和递归返回值。

其中,递归终止条件是指递归过程中达到的最基本的问题,需要在函数或过程内部判断是否满足终止条件;

递归调用是指在函数或过程内部调用自身,用来解决相同或类似的子问题;

递归返回值是指递归过程中返回的结果,通常是通过将多个子问题的解合并为整个问题的解。

C. 递归的分类

递归算法可以根据递归调用的方式分为直接递归和间接递归。直接递归是指函数或过程直接调用自身,而间接递归是指函数或过程通过调用其他函数或过程间接调用自身。

III. 递归算法的应用

A. 树形结构

递归算法可以用来遍历树形结构,例如二叉树的遍历、树的遍历、图的深度优先遍历等。递归算法可以简化代码实现,提高程序的可读性和可维护性。下面以二叉树的遍历为例,介绍递归算法的应用。

前序遍历

前序遍历是指先访问根节点,然后遍历左子树,最后遍历右子树。前序遍历的递归实现如下:

void preorder(TreeNode* root) {
    if (root == nullptr) {
        return;
    }
    visit(root);
    preorder(root->left);
    preorder(root->right);
}

在递归过程中,先访问当前节点,然后递归遍历左子树和右子树。在访问子节点之前,需要先判断当前节点是否为空。

中序遍历

中序遍历是指先遍历左子树,然后访问根节点,最后遍历右子树。中序遍历的递归实现如下:

void inorder(TreeNode* root) {
    if (root == nullptr) {
        return;
    }
    inorder(root->left);
    visit(root);
    inorder(root->right);
}

在递归过程中,先递归遍历左子树,然后访问当前节点,最后递归遍历右子树。在访问子节点之前,需要先判断当前节点是否为空。

后序遍历

后序遍历是指先遍历左子树,然后遍历右子树,最后访问根节点。后序遍历的递归实现如下:

void postorder(TreeNode* root) {
    if (root == nullptr) {
        return;
    }
    postorder(root->left);
    postorder(root->right);
    visit(root);
}

在递归过程中,先递归遍历左子树,然后递归遍历右子树,最后访问当前节点。在访问子节点之前,需要先判断当前节点是否为空。

B. 分治算法

分治算法是一种递归算法的应用,它将一个大问题分解为多个相同或类似的子问题,并且递归地求解这些子问题,然后将子问题的解合并为整个问题的解。分治算法的典型应用包括归并排序、快速排序和二分查找等。

  1. 归并排序

归并排序是一种稳定的排序算法,它将一个数组分成两个部分,然后递归地对这两个部分进行排序,最后将这两个已经排序好的数组合并成一个有序数组。归并排序的递归实现如

void merge_sort(int arr[], int left, int right) {
    if (left < right) {
        int mid = left + (right - left) / 2;
        merge_sort(arr, left, mid);
        merge_sort(arr, mid + 1, right);
        merge(arr, left, mid, right);
    }
}

void merge(int arr[], int left, int mid, int right) {
    int n1 = mid - left + 1;
    int n2 = right - mid;
    int L[n1], R[n2];
    for (int i = 0; i < n1; i++) {
        L[i] = arr[left + i];
    }
    for (int j = 0; j < n2; j++) {
        R[j] = arr[mid + 1 + j];
    }
    int i = 0, j = 0, k = left;
    while (i < n1 && j < n2) {
        if (L[i] <= R[j]) {
            arr[k] = L[i];
            i++;
        } else {
            arr[k] = R[j];
            j++;
        }
        k++;
    }
    while (i < n1) {
        arr[k] = L[i];
        i++;
        k++;
    }
    while (j < n2) {
        arr[k] = R[j];
        j++;
        k++;
    }
}

在递归过程中,将数组分成两个部分,然后递归地对这两个部分进行排序,最后将这两个已经排序好的数组合并成一个有序数组。在递归结束之后,使用merge函数将两个已经排序好的数组合并成一个有序数组。

C. 动态规划

动态规划是一种用来解决具有重叠子问题和最优子结构性质的问题的算法。它将原问题分解成多个子问题,并且递归地求解这些子问题,将子问题的解合并为整个问题的解。动态规划的典型应用包括背包问题、最长公共子序列问题和最短路径问题等。

背包问题

背包问题是指给定一个背包容量和一些物品,每个物品有自己的价值和重量,要求在不超过背包容量的前提下,选择一些物品使得它们的总价值最大。背包问题可以使用动态规划算法进行求解,其递归实现如下:

int knapsack(int capacity, int weight[], int value[], int n) {
    if (n == 0 || capacity == 0) {
        return 0;
    }
    if (weight[n - 1] > capacity) {
        return knapsack(capacity, weight, value, n - 1);
    } else {
        return max(value[n - 1] + knapsack(capacity - weight[n - 1], weight, value, n - 1), knapsack(capacity, weight, value, n - 1));
    }
}

量和当前物品的重量的大小关系。如果当前物品的重量大于背包的容量,则不能选择当前物品,只能在前n-1个物品中选择;否则,有两种选择:选择当前物品和不选择当前物品。如果选择当前物品,递归地求解剩下的前n-1个物品在背包剩余容量下的最大价值;如果不选择当前物品,递归地求解剩下的前n-1个物品在当前背包容量下的最大价值。最后,将选择当前物品和不选择当前物品的两种情况下的最大价值取最大值作为背包在当前容量下的最大价值。

最长公共子序列问题

最长公共子序列问题是指给定两个字符串,要求找出它们的最长公共子序列。最长公共子序列问题可以使用动态规划算法进行求解,其递归实现如下:

int lcs(string X, string Y, int m, int n) {
    if (m == 0 || n == 0) {
        return 0;
    }
    if (X[m - 1] == Y[n - 1]) {
        return 1 + lcs(X, Y, m - 1, n - 1);
    } else {
        return max(lcs(X, Y, m - 1, n), lcs(X, Y, m, n - 1));
    }
}

在递归过程中,先判断当前字符X[m-1]和Y[n-1]是否相等。如果相等,它们就可以作为最长公共子序列的一个元素,递归地求解剩下的前m-1个字符和前n-1个字符的最长公共子序列长度,并且将结果加1;如果不相等,就要分别递归地求解前m个字符和前n-1个字符的最长公共子序列长度,以及前m-1个字符和前n个字符的最长公共子序列长度,并取它们的最大值作为最长公共子序列的长度。

最短路径问题

最短路径问题是指给定一个有向图和其中每条边的权值,要求找出从一个起点到一个终点的最短路径。最短路径问题可以使用动态规划算法进行求解,其递归实现如下:

  ```
  int shortest_path(vector<vector<int>>& graph, int u, int v, int k) {
      int n = graph.size();
      // 如果起点和终点相同,则路径长度为0
      if (u == v) {
          return 0;
      }
      // 如果已经没有中转机会,则返回无穷大
      if (k == 0) {
          return INT_MAX;
      }
      // 初始化最短路径长度为无穷大
      int res = INT_MAX;
      // 对于每个邻接节点,递归计算从该邻接节点到终点的最短路径
      for (int i = 0; i < n; i++) {
          if (graph[u][i] > 0) {
              res = min(res, graph[u][i] + shortest_path(graph, i, v, k - 1));
          }
      }
      return res;
  }
  ```

  

在递归过程中,先判断当前k的值,如果k等于0并且起点和终点不相同,则说明已经没有中转机会了,返回无穷大。否则,遍历起点的每个邻接节点,并递归地计算从该邻接节点到终点的最短路径长度,然后取最小值加上当前节点到邻接节点的边权值,就可以得到从起点到终点经过k个中转节点的最短路径长度。最后返回所有经过k个中转节点的路径长度的最小值,即为从起点到终点的最短路径长度。

最短路径问题的递归实现虽然简单易懂,但由于存在大量的重复计算,时间复杂度较高。因此,实际应用中通常采用动态规划算法进行求解,可以有效地避免重复计算,提高算法的效率。

D. 搜索问题

递归算法也常用于搜索问题中,例如深度优先搜索(DFS)和广度优先搜索(BFS)。以DFS为例,其递归实现如下:

void dfs(vector<vector<int>>& graph, vector<bool>& visited, int u) {
    visited[u] = true; // 标记当前节点已访问
    // 遍历当前节点的所有邻接节点
    for (int i = 0; i < graph[u].size(); i++) {
        int v = graph[u][i];
        if (!visited[v]) { // 如果邻接节点未访问,则递归访问该节点
            dfs(graph, visited, v);
        }
    }
}

在DFS中,首先访问起点,并标记其已访问,然后遍历起点的所有邻接节点,如果邻接节点未访问,则递归访问该节点。重复以上过程,直到所有连通节点都被访问过为止。

递归算法的优点是代码简洁易懂,容易实现。但由于递归过程中需要不断压栈,因此递归深度过大时容易出现栈溢出等问题。此外,由于递归算法存在大量的函数调用和栈操作,因此在时间和空间效率上可能不如迭代算法。因此,在实际应用中需要根据具体问题的特点选择适合的算法和数据结构。

IV. 递归算法的优缺点

A. 优点

  1. 代码简洁易懂:递归算法通常可以用更简洁的代码来描述问题的本质,更符合人类思维习惯,易于理解和维护。

  1. 递归思想自然:递归算法的本质是将问题分解为更小的子问题来求解,这与许多问题的本质相符,例如树形结构、递归定义等,因此递归思想自然而然。

  1. 递归算法的实现常常比迭代算法更为简单:某些问题采用递归算法可以省去迭代算法中需要使用的复杂数据结构,代码实现更为简单。

B. 缺点

  1. 递归算法的效率较低:递归算法由于需要不断压栈、弹栈,因此在时间和空间上消耗较大,容易导致栈溢出等问题。

  1. 递归算法不易调试:递归算法的调试较为困难,因为在递归调用的过程中,每个函数的返回值依赖于递归调用下一层的函数,当程序出现错误时,很难确定错误是在哪一层递归调用中出现的。

  1. 递归算法容易受限于计算机的栈深度:在实际运行中,由于计算机栈深度的限制,递归算法所能解决的问题也受到了一定的限制。

C. 递归算法的适用场景

递归算法常用于树形结构、图形遍历、分治算法、动态规划等问题的求解。在这些问题中,递归算法能够更好地描述问题的本质,提高算法的可读性和易维护性。但对于某些问题,如计算斐波那契数列等,递归算法的效率较低,应该采用迭代算法进行求解。因此,在选择算法时需要根据问题的特点和需求选择合适的算法和数据结构。

V. 递归算法的实例讲解

A. 阶乘算法

阶乘算法是递归算法的经典应用之一。阶乘可以表示为 $n! = n \times (n-1) \times (n-2) \times ... \times 1$。使用递归算法可以方便地计算阶乘。

递归实现代码如下:

int factorial(int n) {
    if (n == 0 || n == 1) {
        return 1;
    } else {
        return n * factorial(n-1);
    }
}

B. 斐波那契数列

斐波那契数列是一个递归定义的数列,其前两个数字为 1,之后的数字为前面两个数字之和。即 $F_0=1, F_1=1, F_n=F_{n-1}+F_{n-2}(n\geq2)$。使用递归算法可以方便地计算斐波那契数列。

递归实现代码如下:

int fibonacci(int n) {
    if (n == 0 || n == 1) {
        return 1;
    } else {
        return fibonacci(n-1) + fibonacci(n-2);
    }
}

C. 二分查找

二分查找是一种常见的查找算法,可以在有序数组中快速查找指定的元素。使用递归算法可以方便地实现二分查找。

递归实现代码如下:

int binarySearch(int arr[], int low, int high, int key) {
    if (low > high) {
        return -1;
    }
    int mid = low + (high - low) / 2;
    if (arr[mid] == key) {
        return mid;
    } else if (arr[mid] > key) {
        return binarySearch(arr, low, mid-1, key);
    } else {
        return binarySearch(arr, mid+1, high, key);
    }
}

D. 其他实例

除了阶乘、斐波那契数列和二分查找外,递归算法还可以应用于其他场景,如:

  • 数组、链表等数据结构的遍历

  • 树形结构的遍历和操作

  • 图的遍历和操作

  • 括号匹配、字符串反转等问题的求解

其中,树形结构的遍历和操作是递归算法的典型应用,如前序遍历、中序遍历、后序遍历、深度优先搜索等。递归算法还可以用于解决一些具有重复子问题的问题,如动态规划算法和分治算法。

VI. 递归算法的注意事项

A. 基线条件的设定

在使用递归算法时,必须要设定好基线条件,也就是递归的终止条件。如果没有设定好基线条件,递归算法将会陷入无限循环中。在编写递归函数时,应当考虑到最简单的情况,并将其设为基线条件。

B. 递归层数的限制

递归算法在实现过程中需要不断地调用自身,因此可能会出现递归层数过多的情况。当递归层数过多时,可能会导致栈溢出等问题,影响程序的运行。因此,在使用递归算法时,应当注意递归层数的限制。

C. 递归算法的时间复杂度

递归算法的时间复杂度与其递归深度相关,因此很难进行精确计算。一般来说,递归算法的时间复杂度可以通过递推公式进行估算。例如,斐波那契数列的递归算法时间复杂度为O(2^n),而归并排序的递归算法时间复杂度为O(nlogn)。

总的来说,虽然递归算法具有简洁、易懂等优点,但在使用时需要注意基线条件的设定、递归层数的限制以及时间复杂度等问题。

猜你喜欢

转载自blog.csdn.net/IamBird/article/details/129210354