分治算法实现对比及栈溢出问题解决
一、啥是分治算法
分治算法,简单来说,就是把一个复杂的大问题,拆分成一堆相互独立、结构相同的小问题,然后逐个击破这些小问题,最后把小问题的答案合并起来,就得到大问题的答案啦。这就好比你要打扫一个超级大的房子,一个人搞太累了,你就把房子分成一个个小房间,每次只打扫一个小房间,最后所有小房间都打扫完了,整个大房子也就干净啦。
分治算法有三个主要步骤:
- 分解:把大问题拆成小问题。
- 解决:处理这些小问题。
- 合并:把小问题的结果合起来,得到大问题的答案。
二、分治算法的递归实现
递归实现分治算法,就是函数自己调用自己。每次调用函数,就相当于处理一个小问题,直到问题小到可以直接解决为止。
示例:用递归实现归并排序(Java 技术栈)
// 归并排序的递归实现
public class MergeSortRecursive {
// 合并两个有序数组
public static void merge(int[] arr, int left, int mid, int right) {
// 计算左右子数组的长度
int n1 = mid - left + 1;
int n2 = right - mid;
// 创建临时数组
int[] L = new int[n1];
int[] R = new int[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;
// 归并的起始位置
int k = left;
while (i < n1 && j < n2) {
if (L[i] <= R[j]) {
arr[k] = L[i];
i++;
} else {
arr[k] = R[j];
j++;
}
k++;
}
// 复制 L[] 的剩余元素
while (i < n1) {
arr[k] = L[i];
i++;
k++;
}
// 复制 R[] 的剩余元素
while (j < n2) {
arr[k] = R[j];
j++;
k++;
}
}
// 归并排序函数
public static void mergeSort(int[] arr, int left, int right) {
if (left < right) {
// 找到中间位置
int mid = left + (right - left) / 2;
// 递归排序左半部分
mergeSort(arr, left, mid);
// 递归排序右半部分
mergeSort(arr, mid + 1, right);
// 合并排序好的两部分
merge(arr, left, mid, right);
}
}
public static void main(String[] args) {
int[] arr = {12, 11, 13, 5, 6, 7};
int len = arr.length;
mergeSort(arr, 0, len - 1);
// 输出排序后的数组
for (int num : arr) {
System.out.print(num + " ");
}
}
}
在这个例子里,归并排序把一个大的数组不断地拆成两半,分别对左右两半进行排序,最后再把排好序的两部分合并起来。递归调用就是不断地调用 mergeSort 函数,把问题规模不断缩小。
递归实现的优缺点
优点:
- 代码简单易懂,逻辑清晰。就像上面的归并排序代码,很直观地体现了分治的思想。
- 符合人类思考问题的方式,很容易实现分治算法的逻辑。
缺点:
- 递归深度过大时,会导致栈溢出。因为每次递归调用都会在栈上分配内存,如果递归层数太多,栈空间就会被耗尽。
- 递归调用会有额外的开销,比如函数调用的时间和空间开销。
三、分治算法的非递归实现
非递归实现就是不用函数自己调用自己,而是用循环来模拟递归的过程。
示例:用非递归实现归并排序(Java 技术栈)
// 归并排序的非递归实现
public class MergeSortNonRecursive {
// 合并两个有序数组
public static void merge(int[] arr, int left, int mid, int right) {
// 计算左右子数组的长度
int n1 = mid - left + 1;
int n2 = right - mid;
// 创建临时数组
int[] L = new int[n1];
int[] R = new int[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;
// 归并的起始位置
int k = left;
while (i < n1 && j < n2) {
if (L[i] <= R[j]) {
arr[k] = L[i];
i++;
} else {
arr[k] = R[j];
j++;
}
k++;
}
// 复制 L[] 的剩余元素
while (i < n1) {
arr[k] = L[i];
i++;
k++;
}
// 复制 R[] 的剩余元素
while (j < n2) {
arr[k] = R[j];
j++;
k++;
}
}
// 非递归归并排序函数
public static void mergeSort(int[] arr) {
int n = arr.length;
// 子数组的大小,从 1 开始,每次翻倍
for (int curr_size = 1; curr_size <= n - 1; curr_size = 2 * curr_size) {
// 遍历所有子数组
for (int left_start = 0; left_start < n - 1; left_start += 2 * curr_size) {
// 找到中间位置
int mid = Math.min(left_start + curr_size - 1, n - 1);
// 找到右边界
int right_end = Math.min(left_start + 2 * curr_size - 1, n - 1);
// 合并两个子数组
merge(arr, left_start, mid, right_end);
}
}
}
public static void main(String[] args) {
int[] arr = {12, 11, 13, 5, 6, 7};
mergeSort(arr);
// 输出排序后的数组
for (int num : arr) {
System.out.print(num + " ");
}
}
}
在这个非递归的归并排序里,我们用两个嵌套的循环来模拟递归的过程。外层循环控制子数组的大小,内层循环遍历所有的子数组,然后把相邻的子数组合并起来。
非递归实现的优缺点
优点:
- 不会有栈溢出的问题,因为没有递归调用,不会在栈上分配大量的内存。
- 性能可能会好一些,因为没有函数调用的额外开销。
缺点:
- 代码相对复杂,逻辑不太直观,理解起来可能需要花点时间。
四、如何避免递归深度过大导致的栈溢出
1. 尾递归优化
尾递归就是递归调用是函数的最后一个操作。有些编译器可以对尾递归进行优化,把递归调用转化为循环,这样就不会有栈溢出的问题了。
示例:尾递归实现阶乘(Java 技术栈)
// 尾递归实现阶乘
public class TailRecursiveFactorial {
public static int factorial(int n, int acc) {
if (n == 0) {
return acc;
} else {
return factorial(n - 1, n * acc);
}
}
public static void main(String[] args) {
int n = 5;
int result = factorial(n, 1);
System.out.println("Factorial of " + n + " is " + result);
}
}
在这个例子里,factorial 函数的递归调用是最后一个操作,有些编译器可以对它进行优化。
2. 转换为非递归实现
就像前面说的归并排序,把递归实现改成非递归实现,用循环来模拟递归的过程,这样就不会有栈溢出的问题了。
3. 手动管理栈
可以自己用一个栈数据结构来模拟系统栈的行为,这样就可以控制递归的深度。
示例:手动管理栈实现深度优先搜索(Java 技术栈)
import java.util.Stack;
// 图的邻接矩阵表示
class Graph {
private int V; // 顶点数
private int[][] adj; // 邻接矩阵
public Graph(int v) {
V = v;
adj = new int[v][v];
for (int i = 0; i < v; ++i)
for (int j = 0; j < v; ++j)
adj[i][j] = 0;
}
// 添加边
public void addEdge(int v, int w) {
adj[v][w] = 1;
}
// 非递归深度优先搜索
public void DFS(int s) {
boolean[] visited = new boolean[V];
Stack<Integer> stack = new Stack<>();
// 标记当前节点为已访问并压入栈
visited[s] = true;
stack.push(s);
while (stack.size() != 0) {
// 弹出栈顶元素
s = stack.pop();
System.out.print(s + " ");
// 遍历栈顶元素的所有邻接节点
for (int i = 0; i < V; ++i) {
if (adj[s][i] == 1 && !visited[i]) {
visited[i] = true;
stack.push(i);
}
}
}
}
public static void main(String[] args) {
Graph g = new Graph(4);
g.addEdge(0, 1);
g.addEdge(0, 2);
g.addEdge(1, 2);
g.addEdge(2, 0);
g.addEdge(2, 3);
g.addEdge(3, 3);
System.out.println("Following is Depth First Traversal:");
g.DFS(2);
}
}
在这个例子里,我们用一个栈来模拟递归调用的过程,手动控制栈的深度,避免了栈溢出的问题。
五、应用场景
分治算法在很多地方都有应用,比如:
- 排序算法:像归并排序、快速排序,都是用分治算法实现的。
- 矩阵乘法:Strassen 算法就是用分治思想来优化矩阵乘法的。
- 最近点对问题:在二维平面上找到距离最近的两个点,也可以用分治算法来解决。
六、注意事项
- 在使用递归实现分治算法时,一定要注意递归深度,避免栈溢出。可以先估算一下递归的最大深度,如果可能会很大,就考虑使用非递归实现或者其他避免栈溢出的方法。
- 非递归实现虽然不会有栈溢出的问题,但是代码可能会比较复杂,在实现的时候要仔细检查逻辑。
七、文章总结
分治算法是一种非常有用的算法思想,它可以把大问题拆成小问题,然后逐个解决。递归实现分治算法代码简单易懂,但是可能会有栈溢出的问题;非递归实现不会有栈溢出问题,但是代码相对复杂。我们可以通过尾递归优化、转换为非递归实现、手动管理栈等方法来避免递归深度过大导致的栈溢出。在实际应用中,要根据具体情况选择合适的实现方式。
评论