数据结构与算法笔记:基础篇 -递归树:如何借助树来求解递归算法的时间复杂度?

news2025/3/1 11:10:18

概述

我们都知道,递归代码的时间复杂度分析起来很麻烦。在《排序(下)》哪里讲过,如何用递推公式,求解归并排序、快速排序的时间复杂度,但是有些情况,比如快排的平均时间复杂度的分析,用递推公式的话,会设计非常复杂的数据推到。

除了用递推公式这种比较复杂的分析方法,有没有更简单的方法呢?本章就来学习另外一种方法,借助递归树来分析递归算法的时间复杂度。


递归树与时间复杂度分析

之前即讲过,递归的思想是,将大问题分为小问题来求解,然后再将小问题分解为小小问题。这样一层一层地分解,直到问题的数据规模被分解得足够小,不用继续递归分解为止。

如果我们把这一层一层的分解过程画成图,它其实就是一棵树。我们个这棵树起一个名字,叫做递归树。下面画了一颗斐波那契数列的递归树,你可以看看。节点里的数字表示数据的规模,一个节点的求解可以分解为左右子节点两个问题的求解。

在这里插入图片描述

通过这个例子,你对递归树的样子应该有一个感性的认识了,看起来并不复杂。现在,我们就来看,如何用递归树来求解时间复杂度。

归并排序算法你还记得把?它的递归实现代码非常简洁。现在我们就借助排序来看看,如何用递归树,来分析递归代码的时间复杂度。

归并排序算法的原理这里就不介绍了。归并排序每次会将数据规模一分为二。我们把归并排序画成递归树,就是下面的样子。

在这里插入图片描述

因为每次分解都是一分为二,所以代价很低,我们把时间上的消耗记作常量 1。归并算法中,比较耗时的操作是归并操作,也就是把两个子数组合并为大数组。从图中我们可以看出,每一层归并曹邹的耗时的时间综合都是一样的,跟要排序的数据规模有关。我们把每一层归并操作消耗的事件记作 n。

现在,只需要知道这棵树的高度 h,用高度 h 乘以每一层的时间消耗 n,就可以得到总的时间复杂度 O ( n ∗ h ) O(n*h) O(nh)

从归并排序的原理和递归树,可以看出来,归并排序递归树是一个满二叉树。前面两篇文章讲到,慢二叉树的高度是 l o g 2 n log_2 n log2n,所以,归并排序算法的高度就是 O ( n l o g n ) O(nlogn) O(nlogn)。这里的时间复杂度都是估算的,对树的高度的计算也没有那么精确,但是这并不影响复杂度的计算结果。

利用递归的时间复杂度分析方法并不难理解,关键还是在实战,所以,接下来会通过三个事件的递归算法,带你实战一下递归的复杂度分析。学完本章后,你应该就能真正掌握递归代码的复杂度分析。

实战一:分析快速排序的时间复杂度

在用递归树推到这钱,先来回忆一下用递归公式的分析方法。回想一下,当时,为什么说用递推公式求解平均时间复杂度非常复杂?

快速排序在最好情况下,每次分区都能一分为二,这个时候用递推公式 T ( n ) = 2 T ( n 2 ) + n T(n)=2T(\frac n 2) + n T(n)=2T(2n)+n 很容易就能推导出时间复杂度是 O ( n l o g n ) O(nlogn) O(nlogn)。但是,我们并不可能每次分区都这么幸运,正好一分为二。

假设平均情况下,每次分区之后,两个分区的大小比例为 1:k。当 k = 9 时,如果用递归公式的方法来求解时间复杂度的话,递推公式就写成 T ( n ) = T ( n 10 ) + T ( 9 n 10 ) + n T(n)=T(\frac n {10}) + T(\frac {9n} {10}) + n T(n)=T(10n)+T(109n)+n

这个公式可以推导出时间复杂度,但是肯定推到过程非常复杂。那我们来看看,用递归树来分析快速排序的平均情况时间复杂度,是不是比较简单呢?

还是取 k 等于 9,也就是说,每次分区都很不平均,一个分区是另一个分区的 9 倍。如果我们把递归分析的过程画成递归树,就是下面的样子:

在这里插入图片描述

快速排序的过程中,每次分区都要遍历带分区区间的所有数据,所以,每一层分区操作所遍历的数据的个数之和就是 n。我们现在只要求出递归树的高度 h,这个快速排序过程遍历的数据个数就是 h * n,也就是说时间复杂度就是 O ( h ∗ n ) O(h*n) O(hn)

因为每次并不是均匀地一分为二,所以递归树不是满二叉树。这样一个递归树的高度是多少呢?

我们知道,快速排序结束的条件就是排序的小区近,大小为 1,也就是说叶子节点里的数据规模是 1.从根节点 n 到叶子节点 1 ,递归树中最短的一个路径每次都乘以 1 10 \frac 1 {10} 101,最长一个路径每次都乘以 9 10 \frac 9 {10} 109。通过计算,可以得到,从根节点到叶子节点的最短路径是 l o g 10 n log_{10}n log10n,最长路径是 l o g 10 9 n log_{\frac {10} 9}n log910n

在这里插入图片描述

所以,遍历数据的个数综合就介于 l o g 10 n log_{10}n log10n l o g 10 9 n log_{\frac {10} 9}n log910n 之间。根据复杂度的大 O 表示法,对数复杂的底数不管是多少,我们统一写成 l o g n logn logn,所以,当分区发小比例是 1:9 时,快速哦爱旭的时间复杂度仍然是 O ( n l o g n ) O(nlogn) O(nlogn)

刚刚假设的是 k=9,那如果 k=99,也就是说,每次分区极不平均,两个区间大小是 1:99 ,这个时候的时间复杂度是多少呢?

可以类比上面的 k=9 的分析过程。当 k=99 时,树的最短路径就是 l o g 100 n log_{100}n log100n,最长路径是 l o g 100 99 n log_{\frac {100} {99}}n log99100n,所以总遍历数据个数介于 l o g 100 n log_{100}n log100n l o g 100 99 n log_{\frac {100} {99}}n log99100n 之间。尽管底数变量,但是时间复杂度仍然是 O ( n l o g n ) O(nlogn) O(nlogn)

也就是说,对于 k 等于 9,99,甚至是 99,…, 只要 k 的值不随 n 变化,是一个实现确定的常量,那快排的时间复杂度就是 O ( n l o g n ) O(nlogn) O(nlogn)。所以,从概率论的角度来说,快排的平均时间复杂度就是 O ( n l o g n ) O(nlogn) O(nlogn)

实战二:分析斐波那契数列的时间复杂度

在递归那篇文章,我们举了一个跨台阶的例子,你还记得吗?那个例子实际上就是斐波那契数列。放了方便你回忆,我把它的代码实现贴在这里。

int f(int n) {
	if (n == 1) return 1;
	if (n == 2) return 2;
	return f(n - 1) + f(n - 2);
}

这样一段代码的时间复杂度是多少呢?

先把上面的递归代码画成递归树,就是下面这个样子。
在这里插入图片描述

这棵树的高度是多少呢?

f(n) 分解为 f(n-1)f(n-2),每次数据规模都是 -1-2,叶子节点的数据规模是 1 或者 2.所以,从根节点到叶子节点,每条路径的长短都是不一样的。如果每次都是 -1。最大路径长度就是 n;如果每层都是 -1,最短路径大约就是 n 2 \frac n 2 2n

每次分解之后的合并操作,只需要一次加法运算,我们把这次加法运算的时间消耗记作 1。所以,从上往下,第一层的总时间消耗是 1,第二层的总时间消耗是 2,第三层的总时间消耗是 2 2 2^2 22。以此类推,第 k 层的数据消耗是 2 k − 1 2^{k-1} 2k1,整个算法的总时间消耗就是每一层时间消耗之和。

如果路径长度为 n,那这个总和就是 2 n − 1 2^n-1 2n1
在这里插入图片描述

如果路径长度都是 n 2 \frac n 2 2n,那整个算法的总的时间消耗就是 2 n 2 − 1 2^{\frac n 2}-1 22n1

在这里插入图片描述

所以,这个算法的时间复杂度就介于 O ( 2 n ) O(2^n) O(2n) O ( 2 n 2 ) O(2^{\frac n 2}) O(22n)。虽然这样得到的结果还不够精确,只是一个范围,但是我们也基本上知道了上面的时间复杂度是指数级的,非常高。

实战三:分析全排列的时间复杂度

前面两个复杂度分析哦度比较简单,再来看一个稍微复杂的。

在高中时都学过排列组合。“如何把 n 个数据的所有排列都找出来”,这就是全排列的问题。

比如,1,2,3 这样三个数据,有下面几种不同的排列:

1,2,3
1,3,2
2,1,3
2,3,1
3,1,2
3,2,1

如何编程打印一组数据的所有排列呢?这里就可以用递归来实现。

若我们确定了最后一位数据,那就变成了求解剩下 n-1 哥数据的排列问题。而最后一位数可以是 n 个数据中的任意一个,因此它的取值就有 n 种情况。所以,“n 个数据的排列” 问题,就可以分解成 n 个 “n-1 个数据排列” 的子问题。

如果写成递推公式,就是下面这个样子:

假设数组中存储的是 1,2,3,…,n。
f(1,2,…,n) = {最后一位是 1, f(n-1)} + {最后一位是 2, f(n-1)} + … + {最后一位是 n, f(n-1)}

如果把递推公式写成代码,就是下面这个样子:

// 调用方式
// int[] a = a={1, 2, 3, 4}; printPermutations(1,4,5);
// k 表示要处理的子数组的数据个数
public void printPermutations(int[] data, int n, int k) {
	if (k == 1) {
		for (int i = 0; i < n; i++) {
			System.out.print(data[i] + " ");
		}
		System.out.println();
	}

	for (int i=0; i < k; i++) {
		int tmp = data[i];
		data[i] = data[k-1];
		data[k-1] = tmp;
		
		printPermutations(data, n, k - 1);
		
		tmp = data[i];
		data[i] = data[k-1];
		data[k-1] = tmp;
	}
}

如果不用前面讲的递归树分析法,这个递归代码的时间复杂度会比较难分析。现在,我们来看下,如何借助递归树,轻松分析出这个代码的时间复杂度。

首先,还是画出递归树。不过,现在的递归树已经不是标准的二叉树了。

在这里插入图片描述

第一次分解有 n 次交换操作,第二层有 n 个节点,每个节点有 n - 1 次交换,所以,第二层总的交换次数是 n*(n-1)。第三层有 n*(n-1) 个节点,每个节点分解需要 n - 2 次交换,所以第三层的总交换次数是 n*(n-1)*(n-2)

以此类推,第 k 层总的交换次数为 n*(n-1)*(n-2)*...*(n-k+1)。最后一次交换就是 n*(n-1)*(n-2)*...*2*1。每层交换之和就是总交换次数。

n + (n-1) + n*(n-1)*(n-2) + ...+ n*(n-1)*(n-2)*...*2*1

这个公式的求和比较复杂,我们看最后一个数, n*(n-1)*(n-2)*...*2*1 等于 n!。也就是说,全排列的递归算法的时间复杂大远大于 O ( n ! ) O(n!) O(n!) 小于 O ( n ∗ n ! ) O(n*n!) O(nn!),虽然没法知道非常精确的时间复杂度,但是这样一个范围已经让我们知道,全排列的时间复杂度是非常高的。

掌握分析的方法很重要,思路不是重点,不要纠结于精确的时间复杂度到底是多少。

小结

本章,用递归树分析了递归代码的时间复杂度。假设我们在排序那一章讲到的递推公式的时间复杂度分析法,我们已经学习了两种递归代码的时间复杂度分析方法了。

有些代码比较适合用递推公式来分析,比如归并排序的时间复杂度、快速排序的最好情况时间复杂度;有些适合采用递归树来分析,比如快速排序的平均时间复杂度。而有些可能两个都不怎么适合使用,比如二叉树的递归前中后序遍历。

时间复杂度分析的理论知识并不多,也不复杂,掌握起来也不难,但是,在我们平时的工作、学习中,面对的代码千差万别,能够灵活应用学到的复杂度分析法,来分析现有的代码的,并不是件简单的事情,所以,平时要多实战、多分析,只有这样,面对任何代码的时间复杂度分析,才能做到游刃有余、毫不畏惧。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1814641.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

远程主机强迫关闭了一个现有的连接redis

引言 在使用 Redis 进行开发和运维过程中&#xff0c;我们可能会遇到 Redis 连接被远程主机强制关闭的情况。本文将介绍造成这种情况的原因&#xff0c;并给出一些处理方法和建议。 远程主机强制关闭连接的原因 远程主机强制关闭连接通常是由于网络不稳定、连接超时、Redis 配…

Linux网络 - HTTP协议

文章目录 前言一、HTTP协议1.urlurl特殊字符 requestrespond 总结 前言 上一章内容我们讲了在应用层制定了我们自己自定义的协议、序列化和反序列化。 协议的制定相对来讲还是比较麻烦的&#xff0c;不过既然应用层的协议制定是必要的&#xff0c;那么肯定已经有许多计算机大佬…

看国足!不破不立!层次越低的家庭,语言攻击性越强——早读(逆天打工人爬取热门微信文章解读)

你昨晚看国足了吗&#xff1f; 引言Python 代码第一篇 洞见 层次越低的家庭&#xff0c;语言攻击性越强第二篇结尾 引言 昨天看了国足比赛 输了韩国一个球 剩下大概率的出线希望 除非泰国赢新加坡 且3个球或者以上 泰国稍强于新加坡 但并不到打进3个球的地步 都觉得2个球已经是…

每日5题Day22 - LeetCode 106 - 110

每一步向前都是向自己的梦想更近一步&#xff0c;坚持不懈&#xff0c;勇往直前&#xff01; 第一题&#xff1a;106. 从中序与后序遍历序列构造二叉树 - 力扣&#xff08;LeetCode&#xff09; class Solution {public TreeNode buildTree(int[] inorder, int[] postorder) {…

质数(素数)的几种判断方法

判断一个数是否为质数/合数是在数据处理中经常遇到的问题&#xff0c;如何解决这个问题&#xff0c;作者总结了如下几种算法。 质数的定义&#xff1a; 一个数如果除了1 和 其本身外&#xff0c;不能被其它数整除&#xff0c;就称这个数为质数&#xff08;或素数&#xff09;…

【计算机体系结构复习】

一.选择题&#xff08;2 X 15) 第五章第六章没有 第一章 第二章 第三章 第四章 第七章 第八章 二.主观题 第一章 1.7 1.11 怎么计算程序执行时间CPI、MIPS CPI变化&#xff0c;系统性能怎么变&#xff08;上课例子&#xff09; p9 例1.3 第二章 哈夫曼…

CVE-2012-2122-mysql未授权访问漏洞复现-vulhub

1.原理 参考&#xff1a;CVE-2012-2122 Mysql身份认证漏洞及利用-CSDN博客 简单来说&#xff0c;除了配置上的问题以外&#xff0c;是密码的验证出现了漏洞&#xff0c;导致尝试次数多了之后直接可以登入 使用&#xff1a;kalivulhub 2.复现 开一下镜像&#xff0c;用的是v…

Michael.W基于Foundry精读Openzeppelin第57期——ReentrancyGuard.sol

Michael.W基于Foundry精读Openzeppelin第57期——ReentrancyGuard.sol 0. 版本0.1 ReentrancyGuard.sol 1. 目标合约2. 代码精读2.1 constructor()2.2 modifier nonReentrant() 0. 版本 [openzeppelin]&#xff1a;v4.8.3&#xff0c;[forge-std]&#xff1a;v1.5.6 0.1 Reen…

docker-compose Install wiki

wiki 前言 最强大和可扩展的开源Wiki软件,使用Wiki.js漂亮而直观的界面,让编写文档成为一种乐趣 前提要求 安装 docker docker-compose 参考创建一键安装wiki wiki 安装目录/wikiwiki端口83admin 端口 84postgres 端口5432postgres 库 wikipostgres 用户 wikijspostgres 密…

【启明智显方案分享】ESP32-S3与GPT AI融合的智能问答嵌入式设备应用解决方案

一、引言 随着物联网&#xff08;IoT&#xff09;和人工智能&#xff08;AI&#xff09;技术的飞速发展&#xff0c;嵌入式设备正逐渐变得智能化。本解决方案是启明智显通过结合ESP32-S3的低功耗、高性能特性和GPT&#xff08;Generative Pre-trained Transformer&#xff09;…

简单了解java中线程的使用

线程 1、线程的相关概念 1.1、并行和并发 并行&#xff1a;在同一时刻&#xff0c;有多个任务在多个CPU上同时执行 并发&#xff1a;在同一时刻&#xff0c;有多个任务在单个CPU上交替执行 1.2、进程和线程 进程&#xff1a;就是在多任务管理系统中&#xff0c;每个独立执…

【MySQL】表的基本增删查改(结合案例)

文章目录 1.前言2.插入数据&#xff08;Create&#xff09;2.1案例2.2单行数据全列插入2.3多行数据指定列插入2.4插入否则更新2.5替换 3. 读取数据(Retireve)3.1案例3.2全列查询3.3指定列查询3.4查询字段为表达式3.5为查询结果起别名3.6去重3.7where条件3.7.1案例 3.8排序3.9筛…

Post Microsoft Build and AI Day 北京开发者日

Microsoft Build 开发者大会 Microsoft Build 开发者大会是微软每年一次的开发者技术盛会&#xff0c;旨在向全球开发者展示微软最新的技术、产品和服务。 刚刚过去的 2024 Microsoft Build 开发者大会围绕 Copilot、生成式 AI、应用程序安全、云平台、低代码等多个技术方向&a…

运维系列.在Docker中使用Grafana

运维专题 在Docker中使用Grafana - 文章信息 - Author: 李俊才 (jcLee95) Visit me at CSDN: https://jclee95.blog.csdn.netMy WebSite&#xff1a;http://thispage.tech/Email: 291148484163.com. Shenzhen ChinaAddress of this article:https://blog.csdn.net/qq_2855026…

Java对象的序列化与反序列化

序列化和反序列化是什么 当两个进程远程通信时&#xff0c;彼此可以发送各种类型的数据。无论是何种类型的数据&#xff0c;都会以二进制序列的形式在网络上传送。比如&#xff1a;我们可以通过http协议发生字符串信息&#xff1b;我们也可以在网络上直接发生Java对象。发送方…

【linux】信号(三)

本章节将会围绕信号处理进行展开讲解 目录 回顾一下&#xff1a;历史问题&#xff1a;内核态 VS 用户态地址空间&#xff1a;键盘的输出如何被检测到&#xff1a;OS如何正常运行&#xff1a;如何执行系统调用&#xff1a; 信号的处理&#xff1a;sigaction&#xff1a;信号的…

QML学习十九:ttf字体库使用

一、前言 在使用QML时&#xff0c;常常自定义按钮&#xff0c;按钮上有显示个图标&#xff0c;其实&#xff0c;那不是图标&#xff0c;是文本&#xff0c;如何显示&#xff1f; 本篇记录&#xff0c;如何导入阿里巴巴字体库&#xff0c;并调用显示。 二、阿里巴巴字体库下载…

分布式系统设计指南

目录 一、分布式简介 二、分布式系统核心概念 2.1 CAP 理论 2.2 BASE 原理 三、分布式系统设计 3.1 微服务拆分 3.2 通信模型 3.3 负载均衡 3.4 数据一致性 3.5 容错限流 3.6 扩展性 3.7 监控预警 3.8 自动化运维 一、分布式简介 分布式系统是由单体应用发展而来的&#xff…

统计绘图 | 既能统计分析又能可视化绘制的技能

在典型的探索性数据分析工作流程中&#xff0c;数据可视化和统计建模是两个不同的阶段&#xff0c;而我们也希望能够在最终的可视化结果中将相关统计指标呈现出来&#xff0c;如何让将两种有效结合&#xff0c;使得数据探索更加简单快捷呢&#xff1f;今天这篇推文就告诉你如何…

使用 Scapy 库编写 TCP 窗口大小探测攻击脚本

一、介绍 1.1 概述 TCP窗口大小探测攻击是一种信息收集攻击&#xff0c;攻击者通过向目标服务器发送特制的TCP数据包&#xff0c;探测目标服务器的TCP接收窗口大小&#xff08;TCP Window Size&#xff09;。了解目标服务器的TCP接收窗口大小&#xff0c;可以帮助攻击者优化后…