数据结构与算法 基本概述
数据结构指的是“一组数据的存储结构”,算法指的是“操作数据的一组方法”。
数据结构是为算法服务的,算法是要作用再特定的数据结构上的。
最常用的数据结构预算法
数据结构:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Tire 树
算法: 递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法
算法设计的要求
正确性
:程序没有语法错误。程序对于一切合法的输入数据包括那些典型、苛刻且带有刁难性的几组输入数据可以得出满足要求的结果(设计算法时需要考虑到所有可能会输入数据)
可读性
:算法写出来是为了人阅读、理解、交流,而不是给计算机看所以我们要有一个良好的代码风格。
健壮性
:当我们输入非法数据时,算法需要进行相应的处理,而不是输出莫名其妙的输出结果。处理出错的方式,不应该是中断程序的执行,而是应返回一个表示错误的值,例如对指针的断言保证数据输入的有效性。
高效性
:更少的算法执行的时间和算法执行的过程中需要的最大存储空间
补充说明:Trie 树基本介绍?
Trie 树,即字典树,又称单词查找树或键树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计和排序大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。(solr 或者 elastic search)
它的优点是:最大限度地减少无谓的字符串比较,查询效率比哈希表高。
Trie 树的核心思想是空间换时间
。利用字符串的公共前缀来降低查询时间的开销以达到提高效率的目的。
Trie 树 数据结构的定义:
#define MAX 26
typedef struct trie {
struct trie* node[MAX];
int v;
} Trie;
Trie 树的基本性质:
根节点不包含字符,除根节点外每一个节点都只包含一个字符。
从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。
每个节点的所有子节点包含的字符都不相同。
举例说明:给你 100000 个长度不超过 10 的单词。对于每一个单词,我们要判断他出没出现过,如果出现了,求第一次出现在第几个位置。
分析结果:这题当然可以用 Hash 来解决,但是本文重点介绍的是 Trie 树,因为在某些方面它的用途更大。比如说对于某一个单词,我们要询问它的前缀是否出现过。这样 Hash 就不好搞了,而用 Trie 还是很简单。
算法时间复杂度 空间复杂度
首先我们先了解什么是算法,算法(Algorithm)是指用来操作数据、解决程序问题的一组方法。但是对于同一个问题,我们去使用不同的算法,结果或许会一样,但不同的地方就在于你所用算法所耗费的资源和时间,此篇博客就是用于去衡量不同算法的优劣。
复杂度的分析
衡量不同算法的优劣,主要还是根据算法所占的空间
和时间
两个维度去考虑。但是,世界上不会存在完美的代码,既不消耗最多的时间,也不占用最多的空间,鱼和熊掌不可得兼,那么我们就需要从中去寻找一个平衡点,使得写出一份较为完美的代码。
时间复杂度概念
在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。
但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。
空间复杂度的概念
空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度 。空间复杂度不是程序占用了多少 bytes 的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟实践复杂度类似,也使用 Big-O notation 渐进表示法。
注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。
Big-O notation 的渐进表示法
Big-O notation:(Big-O complexity)是用于描述函数渐进行为的数学符号。
在 vscode 运行 cpp 介绍
C 语言是结构化和模块化的语言,适合处理较小规模的程序。对于复杂的问题,规模较大的程序,需要高度的抽象和建模时,C语言则不合适。为了解决软件危机, 20世纪80年代, 计算机界提出了 OOP(object oriented programming 面向对象思想),支持面向对象的程序设计语言应运而生。
MinGW 基本介绍
MinGW,是 Minimalist GNUfor Windows 的缩写。它是一个可自由使用和自由发布的Windows 特定头文件和使用 GNU 工具集导入库的集合,允许你在 GNU/Linux 和Windows 平台生成本地的 Windows 程序而不需要第三方 C 运行时(C Runtime)库。
MinGW 官方网站:https://sourceforge.net/projects/mingw/
因为我们只是为了编译 C/C++ 程序,所以只需安装 mingw-developer-toolkit、mingw32-base、mingw32-gcc-g++、msys-base 这 4 个组件即可。
当然为了方便,也可以直接下载环境包:下载地址
配置环境变量:path 路径添加 ming64 bin 包目录,cmd 运行 gcc -v
MinGW 编译 教程
用MinGW编译,先生成main.o文件
gcc -c main.cpp
接着把 main.o 和函数库 mylib.lib 连接起来生成 main.exe 文件
g++ -o main.exe main.o mylib.lib
配置相关信息
创建 tasks.json 文件
主菜单栏选择 Terminal -> Configure Default Build Task,从下拉栏中选择 g++.exe build active file,来编译在编辑栏活跃的源文件。
tasks.json 文件告诉vscode如何编译这个程序,调用 g++ 编译器将源文件编译成可执行文件。
launch.json 文件
从主菜单,选择 Run -> Add Configuration … 之后选择 C++(GDB/LLDB),下拉栏中选择g++.exe build and debug active file.
program:调试入口文件的地址
cwd:程序启动调试的目录
miDebuggerPath:调试器的路径
注意:preLaunchTask 指定了在启动调试前应该执行的任务,因此应该和tasks.json文件中的label保持一致。
运行成功结果如下:
特别注意:路径中文问题
建议 cpp 项目在 Vscode 打开,路径不要带中文,血的教训。
#include报错 找不到头文件
解决方法:配置编译器路径,按快捷键 Ctrl+Shift+P 调出命令面板,输入C/C++,选择“Edit Configurations(UI)”进入配置,选定你的编译器,比如我的就是“D:\aaakkk\cpp\mingw64\bin\c++.exe”,还有在IntelliSense 模式选定“windows-gcc-x64”
补充说明:“using namespace std” 意思
在标准 C++ 以前,都是用 #include <iostream.h> 这样的写法的,因为要包含进来的头文件名就是 iostream.h。
标准C++引入了名字空间的概念,并把 iostream 等标准库中的东西封装到了 std 名字空间中,同时为了不与原来的头文件混淆,规定标准 C++ 使用一套新的头文件,这套头文件的文件名后不加 .h 扩展名,如 iostream、string 等等,并且把原来 C 标准库的头文件也重新命名。
例如原来的 string.h 就改成 cstring(就是把 .h 去掉,前面加上字母 c),所以头文件包含的写法也就变成了 #include。
递归算法 Recursion Algorithm
程序调用自身的编程技巧称为递归(recursion)。递归做为一种算法在程序设计语言中广泛应用。
递归思维是一种从下向上的思维方式,使用递归算法往往可以简化我们的代码,而且还帮我们解决了很复杂的问题。
一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。
递归的能力在于用有限的语句来定义对象的无限集合。一般来说,递归需要有边界条件
、递归前进段
和递归返回段
。当边界条件不满足时,递归前进;当边界条件满足时,递归返回。
递归本质:就是在运行的过程中调用自己。
构成递归需具备的条件:
- 子问题须与原始问题为同样的事,且更为简单。
- 不能无限制地调用本身,须有个出口,化简为非递归状况处理。
递归算法 常见应用
- 数据的定义是按递归定义的。(Fibonacci 函数)
- 问题解法按递归算法实现。这类问题虽则本身没有明显的递归结构,但用递归求解比迭代求解更简单,如 Hanoi 问题。
- 数据的结构形式是按递归定义的。
递归的缺点:递归算法解题相对常用的算法如普通循环等,运行效率较低。因此,应该尽量避免使用递归,除非没有更好的算法或者某种特定情况,递归更为适合的时候。在递归调用的过程当中系统为每一层的返回点、局部量等开辟了栈来存储。递归次数过多容易造成栈溢出等。
利用递归算法实现二分查找
例如: 使用二分查找算法在数组 { 0,1,2,3,4,5,6,7,8,9 } 中查找元素 7。
首先编写二分查找(递归 / 迭代算法)
递归算法二分查找
int BinarySearch_Iteration(int *arr, const int n, const int res)
{
int low = 0;
int high = n - 1;
int mid;
while (low <= high)
{
mid = low + (high - low);
if (res == arr[mid])
{
return mid;
}
else if (res < arr[mid])
{
high = mid - 1;
}
else if (res > arr[mid])
{
low = mid + 1;
}
}
return -1;
}
递归算法二分查找
int BinarySearch_Recursion(int *arr, const int low, const int high, const int res)
{
if (low <= high)
{
int mid = low + (high - low);
if (res == arr[mid])
{
return mid;
}
else if (res < arr[mid])
{
return BinarySearch_Recursion(arr, low, mid - 1, res);
}
else if (res > arr[mid])
{
return BinarySearch_Recursion(arr, mid + 1, high, res);
}
}
}
贪心算法 Greedy Algorithm
贪婪算法(贪心算法)是指在对问题进行求解时,在每一步选择中都采取最好或者最优(即最有利)的选择,从而希望能够导致结果是最好或者最优的算法。贪婪算法所得到的结果不一定是最优的结果(有时候会是最优解),但是都是相对近似(接近)最优解的结果。
贪心算法(Greedy Alogorithm)基本概述
贪心算法概述:贪心算法(Greedy Alogorithm)又叫登山算法,它的根本思想是逐步到达山顶,即逐步获得最优解,是解决最优化问题时的一种简单但是适用范围有限的策略。
贪心算法是对某些求解最优解问题的最简单、最迅速的技术。某些问题的最优解可以通过一系列的最优的选择即贪心选择来达到。但局部最优并不总能获得整体最优解,但通常能获得近似最优解。
在每一部贪心选择中,只考虑当前对自己最有利的选择,而不去考虑在后面看来这种选择是否合理。
贪心算法 钱币找零问题
这个问题在我们的日常生活中就更加普遍了。假设1元、2元、5元、10元、20元、50元、100元的纸币分别有c0, c1, c2, c3, c4, c5, c6张。现在要用这些钱来支付K元,至少要用多少张纸币?
用贪心算法的思想,很显然,每一步尽可能用面值大的纸币即可。在日常生活中我们自然而然也是这么做的。在程序中已经事先将Value按照从小到大的顺序排好。
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 7;
int Count[N] = {3, 0, 2, 1, 0, 3, 5};
int Value[N] = {1, 2, 5, 10, 20, 50, 100};
int solve(int money)
{
int num = 0;
for (int i = N - 1; i >= 0; i--)
{
int c = min(money / Value[i], Count[i]);
money = money - c * Value[i];
num += c;
}
if (money > 0)
num = -1;
return num;
}
int main()
{
int money;
cin >> money;
int res = solve(money);
if (res != -1)
cout << res << endl;
else
cout << "NO" << endl;
}
最优装载问题问题描述
有一批集装箱要装上一艘载重量为 C 的轮船,其中集装箱 i 的重量为 Wi,要求在装载体积不受限制的情况下,将尽可能多的集装箱装上轮船。
算法分析:采用重量轻者先装的贪心选择策略,可产生最优装载问题的最优解。
#include <iostream>
#include "algorithm"
using namespace std;
int main()
{
int num; //定义箱子个数
double capacity; //定义船的最大承载量
cout << "输入箱子个数以及船的最大承载量:" << endl;
cin >> capacity >> num;
cout << "输入每个箱子的重量,用空格分开:" << endl;
double weights[num];
for (int i = 0; i < num; i++)
{
cin >> weights[i];
}
sort(weights, weights + num); //快排将箱子的重量由小到大进行排序
double temp = 0.0; //中间值
int count = 0; //计数器
for (int i = 0; i < num; i++)
{
temp += weights[i];
if (temp <= capacity)
{
count++;
}
else
{
break;
}
}
cout << "能装入箱子最大数量:" << count << endl;
return 0;
}
贪心选择性质
它是指所求问题的整体最优解可以通过一系列局部最优的选择来达到。这是贪心算法一个非常重要的要素,也是和动态规划算法的主要区别。
在贪心算法中,仅在当前状态下做出最好选择,然后再去解做出这个选择后产生的相应的子问题,贪心算法所做出的贪心选择可以依赖于以往所做过的选择,但绝不依赖将来所做的选择,也不依赖于子问题的解,因此贪心算法通常以自顶向下的方式进行,每做出一次贪心选择就将问题规模缩小。
深度优先搜索 广度优先搜索
深度优先搜索(DFS)和广度优先搜索(BFS)都是常见的搜索算法。在学习DFS和BFS之前,我们首先得知道递归函数的概念。
递归函数
通俗地讲,一个函数自己调用自己的行为就叫递归,该函数就叫递归函数。如计算一个数的阶乘,就可以利用递归来实现。
我们知道一个数的阶乘可以等于这个数乘上这个数减1的阶乘,如 ,便有递推式:
规定 ,便可以很容易地编写出如下函数:
int func(int n) {
if (n == 0) { return 1; }
return n * f(n-1);
}
递归函数必须要有循环退出的条件,在这段代码中,
深度优先搜索
深度优先搜索(DFS,Depth-First Search)是搜索算法的一种,它从某一个状态开始,不断地转移状态直到无法转移,然后回退到前一步的状态,继续转移到其他状态,如此不断重复,直到找到最终的解。
我们先来看一下深度优先搜索的搜索树:右边 Depth First Search
从这个搜索树中可以看出,DFS是从根节点出发,每次遍历它的第一个孩子节点,当遍历到叶子节点时候,回退一步返回到它的父亲节点,接着遍历父亲节点的其它孩子节点。如此重复,直到遍历完所有节点。
DFS的题目大致可以分为两类:
1,对图的连通性进行检验:如迷宫问题,图的条件搜索。
2,DFS搜索顺序和规则问题,通过你穷举所有答案,找出符合条件的解。即爆搜问题。
DFS连通性分析:
在测试连通性是,DFS的思路是与人们的思想是一致的,在一条路上,我是否可以在这条路上一直走下去,如果走不通,那我就返回原来的节点,换个方向,再沿着一条路走下去,直到成功。
算法的搜索遍历图的步骤
(1)首先找到初始节点A,
(2)依此从A未被访问的邻接点出发,对图进行深度优先遍历
(3)若有节点未被访问,则回溯到该节点,继续进行深度优先遍历
(4)直到所有与顶点A路径想通的节点都被访问过一次
二叉树深度优先搜索模板(前序DFS搜索)
void DFS(TreeNode* root){
if(root == nullptr) return;
cout << root->val << " "; // 输出当前节点
// 这里不需要标记当前节点为已访问,因为二叉树不会往回走
DFS(root -> lchild);
DFS(root -> rchild);
}
广度优先搜索算法
广度优先搜索算法(Breadth-First Search,BFS)是一种盲目搜寻法,目的是系统地展开并检查图中的所有节点,以找寻结果。换句话说,它并不考虑结果的可能位置,彻底地搜索整张图,直到找到结果为止。BFS并不使用经验法则算法。
广度优先搜索让你能够找出两样东西之间的最短距离,不过最短距离的含义有很多,使用广度优先搜索应用:
编写国际跳棋AI,计算最少走多少步就可获胜;
编写拼写检查器,计算最少编辑多少个地方就可将错拼的单词改成正确的单词,如将READED改为READER需要编辑一个地方;
根据你的人际关系网络找到关系最近的医生。
广度优先搜索是一种“盲目”搜索,所有结点的拓展都遵循“先进先出”的原则,所以采用“队列”来存储这些状态。广度优先搜索的算法框架如下:
void bfs() {
初始化,初始状态存入队列;
队列首指针head=0; 尾指针tail=1;
do {
指针head后移一位,指向待扩展结点;
for (int i = 1;i <= max; ++i) { //max为产生子结点的规则数
if (子结点符合条件) {
tail指针增1,把新结点存入列尾;
if (新结点与原已产生结点重复) 删去该结点(取消入队,tail减1);
else if (新结点是目标结点) 输出并退出;
}
}
} while(head < tail);//队列为空
}
广度优先搜索和深度优先搜索的比较
在广度优先搜索中,可以看出是逐步求解的,反复的进入与退出,将当前的所有可行解都记录下来,然后逐个去查看。 在DFS中我们说关键点是递归以及回溯,在BFS中,关键点则是状态的选取和标记。
对于这两个搜索方法,其实我们是可以轻松的看出来,他们有许多差异与许多相同点的。
数据结构上的运用
DFS用递归的形式,用到了 栈 结构,先进后出。
BFS选取状态用 队列 的形式,先进先出。
算法复杂度
DFS的复杂度与BFS的复杂度大体一致,不同之处在于遍历的方式与对于问题的解决出发点不同,DFS适合目标明确,而BFS适合大范围的寻找。
思想
思想上来说这两种方法都是穷竭列举所有的情况。
动态规划 Dynamic Programming
动态规划过程是:每次决策依赖于当前状态,又随即引起状态的转移。一个决策序列就是在变化的状态中产生出来的,所以,这种多阶段最优化决策解决问题的过程就称为动态规划(DP)。
是一种在数学、计算机科学和经济学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。 动态规划算法是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。
什么是动态规划?
动态规划(Dynamic Programming)对于子问题重叠的情况特别有效,因为它将子问题的解保存在表格中,当需要某个子问题的解时,直接取值即可,从而避免重复计算。
动态规划是一种灵活的方法,不存在一种万能的动态规划算法可以解决各类最优化问题(每种算法都有它的缺陷)。所以除了要对基本概念和方法正确理解外,必须具体问题具体分析处理,用灵活的方法建立数学模型,用创造性的技巧去求解。
动态规划 基本策略
基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。
动态规划中的子问题往往不是相互独立的(即子问题重叠)。在求解的过程中,许多子问题的解被反复地使用。为了避免重复计算,动态规划算法采用了填表来保存子问题解的方法。
动态规划 适用问题
那么什么样的问题适合用动态规划的方法来解决呢?
适合用动态规划来解决的问题,都具有下面三个特点:最优化原理、无后效性、有重叠子问题。
(1)最优化原理:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。
(2)无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。
(3)有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势。
动态规划 案例
题目:给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:一个机器人每次只能向下或者向右移动一步。
输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:因为路径 1→3→1→1→1 的总和最小。
算法解析:
由于路径的方向只能是向下或向右,因此网格的第一行的每个元素只能从左上角元素开始向右移动到达,网格的第一列的每个元素只能从左上角元素开始向下移动到达,此时的路径是唯一的,因此每个元素对应的最小路径和即为对应的路径上的数字总和。
对于不在第一行和第一列的元素,可以从其上方相邻元素向下移动一步到达,或者从其左方相邻元素向右移动一步到达,元素对应的最小路径和等于其上方相邻元素与其左方相邻元素两者对应的最小路径和中的最小值加上当前元素的值。由于每个元素对应的最小路径和与其相邻元素对应的最小路径和有关,因此可以使用动态规划求解。
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
if (grid.size() == 0 || grid[0].size() == 0) {
return 0;
}
int rows = grid.size(), columns = grid[0].size();
auto dp = vector < vector <int> > (rows, vector <int> (columns));
dp[0][0] = grid[0][0];
for (int i = 1; i < rows; i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
for (int j = 1; j < columns; j++) {
dp[0][j] = dp[0][j - 1] + grid[0][j];
}
for (int i = 1; i < rows; i++) {
for (int j = 1; j < columns; j++) {
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
}
return dp[rows - 1][columns - 1];
}
};
class Solution:
def minPathSum(self, grid: List[List[int]]) -> int:
if not grid or not grid[0]:
return 0
rows, columns = len(grid), len(grid[0])
dp = [[0] * columns for _ in range(rows)]
dp[0][0] = grid[0][0]
for i in range(1, rows):
dp[i][0] = dp[i - 1][0] + grid[i][0]
for j in range(1, columns):
dp[0][j] = dp[0][j - 1] + grid[0][j]
for i in range(1, rows):
for j in range(1, columns):
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j]
return dp[rows - 1][columns - 1]
单词拆分案例:
给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。
注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
输入: s = "leetcode", wordDict = ["leet", "code"]
输出: true
解释: 返回 true 因为 "leetcode" 可以由 "leet" 和 "code" 拼接成。
输入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
输出: false
算法解析:
代码展示:
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
n = len(s)
dp = [False]*(n + 1)
dp[0] = True
for i in range(n):
for j in range(i+1,n+1):
if(dp[i] and (s[i:j] in wordDict)):
dp[j]=True
return dp[-1]
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
int n =s.size();
unordered_set<string> hash;
for(string word : wordDict){
hash.insert(word);
}
vector<bool>f(n + 1, false);
f[0] = true;
for(int i = 1; i <= n; i++){
for(int j = 0; j < i; j++)
if(f[j] && hash.find(s.substr(j, i - j)) != hash.end()){
f[i] = true;
break;
}
}
return f[n];
}
};
回溯算法 Backtrack Algorithm
回溯实际上是一种试探算法,这种算法跟暴力搜索最大的不同在于,在回溯算法里,是一步一步地小心翼翼地进行向前试探,会对每一步探测到的情况进行评估,如果当前的情况已经无法满足要求,那么就没有必要继续进行下去,也就是说,它可以帮助我们避免走很多的弯路。
回溯算法的特点在于,当出现非法的情况时,算法可以回退到之前的情景,可以是返回一步,有时候甚至可以返回多步,然后再去尝试别的路径和办法。这也就意味着,想要采用回溯算法,就必须保证,每次都有多种尝试的可能。
原理:「回溯是递归的副产品,只要有递归就会有回溯」,所以回溯法也经常和二叉树遍历,深度优先搜索混在一起,因为这两种方式都是用了递归。
回溯算法 应用解决问题:
组合问题:N 个数里面按一定规则找出 k 个数的集合
排列问题:N 个数按一定规则全排列,有几种排列方式
切割问题:一个字符串按一定规则有几种切割方式
子集问题:一个 N 个数的集合里有多少符合条件的子集
棋盘问题:N 皇后,解数独等等
回溯法确实不好理解,所以需要把回溯法抽象为一个图形来理解就容易多了,「在后面的每一道回溯法的题目我都将遍历过程抽象为树形结构方便大家的理解」。
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
N 皇后问题:
n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互。
给你一个整数 n ,返回 n 皇后问题 不同的解决方案的数量。
#include<vector>
#include<algorithm>
#include<cstring>
#include<string>
#include<iostream>
using namespace std;
class Solution {
public:
vector<vector<string> >solveNQueens(int n)
{
vector<vector<string> >result;
vector<vector<int> >mark;
vector<string>location;
for (int i = 0;i < n;++i)
{
mark.push_back((vector<int>()));
for (int j = 0;j < n;++j)
{
mark[i].push_back(0);
}
location.push_back("");
location[i].append(n, '.');
}
generate(0, n, location, result, mark);
return result;
}
private:
void put_down_the_queen(int x, int y,//棋盘函数
vector<vector<int> >& mark)
{
static const int dx[] = { -1,1,0,0,-1,-1,1,1 };
static const int dy[] = { 0,0,-1,1,-1,1,-1,1 };
mark[x][y] = 1;
for (int i = 1;i < mark.size();++i)
{
for (int j = 0;j < 8;++j)
{
int new_x = x + i * dx[j];
int new_y = y + i * dy[j];
if (new_x >= 0 && new_x < mark.size()
&& new_y >= 0 && new_y < mark.size())
mark[new_x][new_y] = 1;
}
}
}
void generate(int k, int n, vector<string>& location,//递归函数
vector<vector<string> >& result, vector<vector<int> >& mark)
{
if (k == n)
{
result.push_back(location);
return;
}
for (int i = 0;i < n;++i)
{
if (mark[k][i] == 0)
{
vector<vector<int> >tmp_mark = mark;
location[k][i] = 'Q';
put_down_the_queen(k, i, mark);
generate(k + 1, n, location, result, mark);
mark = tmp_mark;
location[k][i] = '.';
}
}
}
};
int main()
{
//测试案例
vector<vector<string> >result;
Solution solve;
result = solve.solveNQueens(4);
for (int i = 0;i < result.size();++i)
{
cout << "i = " << i<<endl;
for (int j = 0;j < result[i].size();j++)
{
cout << result[i][j].c_str()<<endl;
}
cout << endl;
}
}
二叉搜索树(二叉排序树)
所谓二叉搜索树,可提供对数时间的元素插入和访问。二叉搜索树的节点放置规则是:任何节点的键值一定大于去其左子树中的每一个节点的键值,并小于其右子树的每一个节点的键值。
二叉搜索树又称二叉排序树,具有以下性质:
若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
它的左右子树也分别为二叉搜索树
注意:二叉搜索树中序遍历的结果是有序的
在二叉树中找到最大值和最小值是很简单的,比较麻烦的是元素的插入和移除。
插入新元素时
:从根节点开始,遇键值较大者就向左,遇键值较小者就向右,一直到尾端,即为插入点。
移除旧元素时
:如果它是叶节点,直接拿走就是了;如果它有一个节点,那就把那个节点补上去;如果它有两个节点,那就把它右节点的最小后代节点补上去。
二叉搜索树 查找元素
思路:二叉搜索树的左子树永远是比根节点小的,而它的右子树则都是比根节点大的值。当前节点比要找的大就往左走,当前元素比要找的小就往右走
public Node search(int key) {
if (root == null) {
return null;
}
Node cur = root;
while (cur != null) {
if (cur.val == key) {
return cur;
} else if (cur.val > key) {
cur = cur.left;
} else {
cur = cur.right;
}
}
return null;
}
二叉搜索树 插入元素
如果是空树直接把元素插入root位置就好了
思路:因为是二叉搜索树就不能插入重复的元素了,且每次插入都是插入到叶子节点的位置。定义一个 cur 从 root 开始,插入的元素比当前位置元素小就往左走,比当前位置元素大就往右走,直到为空,所以就需要再定义一个变量 parent 记住 cur 的前面的位置。
最后再判断插入到 parent 的左子树还是右子树位置
public boolean insert(int key) {
Node node = new Node(key);
if (root == null) {
this.root = node;
return true;
}
Node parent = null;
Node cur = root;
while (cur != null) {
if (cur.val == key) {
return false;
} else if (cur.val > key) {
parent = cur;
cur = cur.left;
} else {
parent = cur;
cur = cur.right;
}
}
if (parent.val > key) {
parent.left = node;
} else {
parent.right = node;
}
return true;
}
平衡二叉树 AVLTree
平衡二叉树 AVLTree
二叉搜索树的查找、插入、删除性能的时间复杂度均为 O(logn) 但这是在期望的前提下,在最好和最坏的前提下差别较大。在最好情况下,二叉查找树的形态和二分查找的判定相似,每次可以缩小一半的查找范围。但是在最坏的情况下,二叉查找树为单支树,即只有从左子树到右子树,每次查找的搜索范围为 n-1,退化为顺序查找。
二叉查找树的效率和树高成反比,那么为了提高二叉查找树的效率这里就引入了平衡二叉树。
什么是平衡二叉树(AVLTREE)?
平衡二叉查找树即平衡二叉树具有以下性质:
1:平衡二叉树可以为空树。
2:左右子树的高度差值的绝对值不超过1。
3:左右子树也是平衡二叉树。
平衡二叉树就是进阶版的二叉搜索树,就是在二叉搜索树每次插入节点的时候进行判断,是否进行调整。下面就来构建一个平衡二叉树。
AVL树失去平衡 4 种状态
如果在 AVL 树中进行插入或删除节点,可能导致 AVL 树失去平衡,这种失去平衡的二叉树可以概括为四种姿态:LL(左左)、RR(右右)、LR(左右)、RL(右左)。
- LL:LeftLeft,也称“左左”。插入或删除一个节点后,根节点的左孩子(Left Child)的左孩子 (Left Child)还有非空节点,导致根节点的左子树高度比右子树高度高2,AVL树失去平衡。
- RR:RightRight,也称“右右”。插入或删除一个节点后,根节点的右孩子(Right Child)的右孩子 (Right Child)还有非空节点,导致根节点的右子树高度比左子树高度高2,AVL树失去平衡。
- LR:LeftRight,也称“左右”。插入或删除一个节点后,根节点的左孩子(Left Child)的右孩子(Right Child)还有非空节点,导致根节点的左子树高度比右子树高度高2,AVL树失去平衡。
- RL:RightLeft,也称“右左”。插入或删除一个节点后,根节点的右孩子(Right Child)的左孩子(Left Child)还有非空节点,导致根节点的右子树高度比左子树高度高2,AVL树失去平衡。
平衡二叉树的调整
左单旋 代码
treePtr singleLeft(treePtr T)
{
treePtr tmpPtr;
tmpPtr = T;
T = T->Left;
tmpPtr->Left = T->Right;
T->Right = tmpPtr;
tmpPtr->Height = max(GetHeight(tmpPtr->Left), GetHeight(tmpPtr->Right)) + 1;
T->Height = max(GetHeight(T->Left), GetHeight(T->Right)) + 1;
return T;
}
右单旋 代码
treePtr singleRight(treePtr T)
{
treePtr tmpPtr;
tmpPtr = T;
T = T->Right;
tmpPtr->Right = T->Left;
T->Left = tmpPtr;
tmpPtr->Height = max(GetHeight(tmpPtr->Left), GetHeight(tmpPtr->Right)) + 1;
T->Height = max(GetHeight(T->Left), GetHeight(T->Right)) + 1;
return T;
}
LR旋转:左结点进行右单旋,然后对其父结点(即当前结点)进行左单旋;
treePtr LeftRight(treePtr T){
T -> Left = singleRight(T -> Left);
return singleLeft(T);
}
RL旋转:右结点进行左单旋,然后对其父结点(即当前结点)进行右单旋;
treePtr RightLeft(treePtr T)
{
T -> Right = singleLeft(T -> Right);
return singleRight(T);
}
案例图例:
红黑树 RBTree Red-Blank Tree
什么是红黑树?
红黑树是一种自平衡二叉排序树,它属于平衡树,但是却没有平衡二叉树那么“平衡”。那么我们首先来看一下平衡二叉树。
红黑树基本性质
1、每个节点要么是红色,要么是黑色,但根节点永远是黑色的;
2、每个红色节点的两个子节点一定都是黑色;
3、红色节点不能连续(也即是,红色节点的孩子和父亲都不能是红色);
4、从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点;
5、所有的叶节点都是是黑色的(注意这里说叶子节点其实是上图中的 NIL 节点);
简单的说,红黑树是一种近似平衡的二叉查找树,其主要的优点就是“平衡“,即左右子树高度几乎一致,以此来防止树退化为链表,通过这种方式来保障查找的时间复杂度为 log(n)。
红黑树为什么会出现呢?
是因为二叉搜索树有可能会出现极端的情况,就是只有一侧有数据,那这样的话就会降级为链表。后来出现了平衡二叉树,但是由于强制平衡所导致付出的代价比较高昂,所以黑红树出现了。
红黑树 与 AVL树 的比较:
- AVL 树的时间复杂度虽然优于红黑树,但是对于现在的计算机,cpu 太快,可以忽略性能差异
- 红黑树的插入删除比 AVL 树更便于控制操作
- 红黑树整体性能略优于 AVL 树(红黑树旋转情况少于AVL树)
Huffman 哈夫曼树(最优树)
哈夫曼树又称最优二叉树,是一种带权路径长度最短的二叉树。所谓树的带权路径长度,就是树中所有的叶结点的权值乘上其到根结点的路径长度(若根结点为 0 层,叶结点到根结点的路径长度为叶结点的层数)。
什么是哈夫曼树?
给定 n 个权值作为 n 个叶子节点,构造一棵二叉树,若该树的带权路径长度(Weighted Path Length of Tree)达到最小, 称这样的二叉树为最优二叉树,也称为哈夫曼树(Huffman Tree)。
哈夫曼树是带权路径长度最短的树,权值较大的节点离根较近。
哈夫曼树重要概念
路径和路径长度
:在一棵树中,从一个结点往下可以达到的孩子或孙子结点之间的通路,称为路径。通路中分支的数目称为路径长度。若规定根结点的层数为 1,则从根结点到第L层结点的路径长度为 。
结点的权及带权路径长度
:若将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。
结点的带权路径长度
:从根结点到该结点之间的路径长度与该结点的权的乘积。
树的带权路径长度
:树的带权路径长度规定为所有叶子结点的带权路径长度之和,记为
重要知识点:
带权路径长度
树的带权路径长度记为 , 个权值 构成一棵有 个叶结点的二叉树,相应的叶结点的路径长度为 。可以证明哈夫曼树的
图 a:
图 b:
哈夫曼树简单应用案例
设有 10000 个数据, 左边的数据组织形式需要进行 31500 次比较;而右边的树进行 22000 次比较。
从根结点开始遍历二叉树,求最小带权路径长度。关键步骤是求出各个叶子结点的路径长度,用此路径长度*此结点的权值就是此结点带权路径长度,最后将各个叶子结点的带权路径长度加起来即可。
int countWPL(HuffmanTree HT, int n)
{
int cur = 2 * n - 2; // 当前遍历到的节点的序号,初始时为根节点序号
int countRoads = 0, WPL = 0; // countRoads保存叶子结点的路径长度
// 构建好赫夫曼树后,把visit[]用来当做遍历树时每个节点的状态标志
// visit[cur]=0表明当前节点的左右孩子都还没有被遍历
// visit[cur]=1表示当前节点的左孩子已经被遍历过,右孩子尚未被遍历
// visit[cur]=2表示当前节点的左右孩子均被遍历过
int visit[maxSize] = {0}; // visit[]是标注数组,初始化为0
// 从根节点开始遍历,最后回到根节点结束
// 当cur为根节点的parent时,退出循环
while (cur != -1)
{
// 左右孩子均未被遍历,先向左遍历
if (visit[cur] == 0)
{
visit[cur] = 1; // 表明其左孩子已经被遍历过了
if (HT[cur].lchild != -1)
{ // 如果当前节点不是叶子节点,则路径长度+1,并继续向左遍历
countRoads++;
cur = HT[cur].lchild;
}
else
{ // 如果当前节点是叶子节点,则计算此结点的带权路径长度,并将其保存起来
WPL += countRoads * HT[cur].weight;
}
}
// 左孩子已被遍历,开始向右遍历右孩子
else if (visit[cur] == 1)
{
visit[cur] = 2;
if (HT[cur].rchild != -1)
{ // 如果当前节点不是叶子节点,则记下编码,并继续向右遍历
countRoads++;
cur = HT[cur].rchild;
}
}
// 左右孩子均已被遍历,退回到父节点,同时路径长度-1
else
{
visit[cur] = 0;
cur = HT[cur].parent;
--countRoads;
}
}
return WPL;
}
如何构造哈夫曼树?
对于给定的有各自权值的 n 个结点,构建哈夫曼树有一个行之有效的办法:
- 在 n 个权值中选出两个最小的权值,对应的两个结点组成一个新的二叉树,且新二叉树的根结点的权值为左右孩子权值的和;
- 在原有的 n 个权值中删除那两个最小的权值,同时将新的权值加入到 n–2 个权值的行列中,以此类推;
- 重复 1 和 2 ,直到所以的结点构建成了一棵二叉树为止,这棵树就是哈夫曼树。
哈夫曼树数据结构
typedef struct HuffmanTree
{
char data; //结点数据
int weight; //权值
int parent;
int lchild;
int rchild;
} HuffNode, * HuffTree;