参考:https://blog.csdn.net/jiaoyangwm/article/details/80808235
https://blog.csdn.net/a2392008643/article/details/81781766
https://mp.weixin.qq.com/s/vn3KiV-ez79FmbZ36SX9lg
本文仅是将他人博客经个人理解转化为简明的知识点,供各位博友快速理解记忆,并非纯原创博客,如需了解详细知识点,请查看参考的各个原创博客。
目录
第一章 数据结构
1.1 数据关系
1.2 逻辑结构和物理结构
第二章 算法
2.1 算法特性
2.2 算法设计要求
2.3 算法时间复杂度
2.4 算法空间复杂度
第三章 线性表
3.1 线性表的顺序存储结构
3.2 线性表的链式存储结构
3.3 相关面试题
第四章 栈、队列和堆
4.1 栈的定义
4.2 队列的定义
4.3 堆的定义
4.4 相关面试题
第五章 串
第六章 树
6.1 树的定义
6.2 二叉树的定义
6.3 二叉树的遍历
6.4 二叉搜索树
6.5 平衡二叉树
6.6 红黑树
6.7 哈夫曼树
6.8 B树、B+树和B*树
6.9 相关面试题
第七章 图
7.1 图的定义
7.2 图的存储结构
7.3 图的遍历
7.4 最短路径算法
7.5 最小生成树
7.6 拓扑排序
7.7 相关面试题
第八章 哈希表
8.1 哈希函数
8.2 哈希冲突
8.3 哈希表的查找
8.4 相关面试题
第九章 查找
9.1 查找的方法
9.2 相关面试题
第十章 排序
10.1 排序算法综述
10.2 冒泡排序
10.3 选择排序
10.4 插入排序
10.5 希尔排序
10.6 归并排序
10.7 快速排序
10.8 堆排序
10.9 计数排序
10.10 桶排序
10.11 基数排序
10.12 相关面试题
数据结构:相互之间存在一种或多种特定关系的数据元素的集合
逻辑结构:数据对象中数据元素的相互关系
物理结构:数据的逻辑结构在计算机中的存储形式,也就是将数据元素存储到存储器
算法是解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列。
输入、输出、有穷性、确定性、可行性
背景:一个算法执行所耗费的时间,从理论上是不能算出来的,必须上机运行测试才能知道。但我们不可能也没有必要对每个算法都上机测试,只需知道哪个算法花费的时间多,哪个算法花费的时间少就可以了。
时间频度:一个算法中的语句执行次数称为语句频度或时间频度,记为T(n)。
时间复杂度:时间频度中,n称为问题的规模,当n不断变化时,时间频度T(n)也会不断变化。但有时我们想知道它变化时呈现什么规律。为此,我们引入时间复杂度概念。若有某个辅助函数f(n),存在一个正常数c使得fn*c>=T(n)恒成立,记作T(n)=O(f(n)),则称O(f(n)) 为算法的时间复杂度。
通常来说,时间复杂度的分析方法有5种方法,分别如下:
1)直接看嵌套层数
例如外层循环n次,内层循环m次,时间复杂度即为O(nm)。
2)级数嵌套求和
除了直接看嵌套层数,还可用级数嵌套求和的方式(适合嵌套变量相关的情况)
for(int i = 1; i <= n; i++)
for(int j = 1; j <= i; j++
for(int k = 1; k <= j; k++)
x = x + 1;
从语句频度推导出时间复杂度的全过程:
3)对数级复杂度
4)时间复杂度会随输入数据集变化
5)T(n)分解估算
空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度,记做S(n)=O(f(n))。
S(n)与辅助变量的个数有关:
线性表(List):由同类型数据元素构成有序序列的线性结构。
元素之间是有顺序的:第一个元素无前驱,最后一个元素无后继,其他元素都有前驱和后继
线性表是有限的
线性表可以利用数组来实现(顺序存储结构),也可以用链表来实现(链式存储结构)。
顺序存储结构:用一段地址连续的存储单元一次存储线性表的数据元素
顺序存储结构的三个属性:
顺序存储结构中
链式存储结构:用一组任意的存储单元存储线性表的数据元素。(这组存储单元可以是连续的,也可以是不连续的,即这些数据元素可以存在内存中未被占用的任意位置)
链式存储结构中的每个结点包含:存储数据元素信息的域(数据域)+存储后继元素地址的域(指针域)
在链式存储结构中:
注:无论是顺序结构还是链式结构(逻辑结构),都可以用数组和链表(物理结构)来实现。两者的特点如下:
数组的特点:数组将元素在内存中连续存放,由于每个元素占用内存相同,可以通过下标迅速访问数组中任何元素(内存地址固定累加),也就是说,它的随机查找效率很高,但插入、删除效率低。同时,数组需要预留空间,在使用前要先申请占内存的大小,可能会浪费内存空间。并且数组不利于扩展,数组定义的空间不够时要重新定义数组。
//声明并初始化一个一维数组
int arr1[m];
memset(arr1, 0, m);
//声明一个多维数组
int arr2[m][n][k];
int *arr3 = new int[m][n][k];
链表的特点:链表的元素在内存中任意存放,通过存在元素中的指针联系到一起。其插入和删除效率高,查找元素效率低。同时,链表不用指定大小,扩展方便,内存利用率高。
//声明链表结点结构体
struct ListNode {
int val;
struct ListNode *next;
ListNode(int x) :
val(x), next(NULL) {
}
};
//创建一个结点
ListNode* node = new ListNode(1);
Q:数组和链表的区别?
A:如上所述。
Q:有什么方法能结合数组和链表的优点?
A:哈希表是数组和链表的折中方案,增加,删除,改写数据的复杂度平均都是O(1),效率非常高。
Q:如何防止数组越界?
A:1)检查传入参数的合法性;2)传参时把数组和数组长度一起传;3)打印数组索引方便检查;4)使用异常捕获。
Q:判断两个链表是否相交?
A:链表相交之后,后面的部分节点全部共用,可以用2个指针分别从这两个链表头部走到尾部,最后判断尾部指针的地址信息是否一样,若一样则代表链表相交。
Q:找出相交链表开始相交的结点?
A:首先计算出两个链表的长度,然后设置两个指针分别指向两个链表,让指向长链表的指针先走长度的差值步(长链长度-短链长度),此后两个指针一起走,直到找到相等的节点。
Q:判断单链表是否有环?
A:定义快慢指针,同时从链表的头结点出发,快指针每次走两步,慢指针每次走一步。如果快指针和慢指针相遇,则链表有环。此时,若要找出入环结点,可以在相遇后令快指针回到头结点,两个指针每次均走一步,第二次相遇的结点即为入环的第一个结点。
Q:给定一个链表的头指针和结点指针,用O(1)时间删除它?
A:用下一个节点数据覆盖要删除的节点,然后删除下一个节点。但是如果节点是尾节点时,该方法就行不通了。
- 栈是限定仅在表尾进行插入和删除操作的线性表
- 队列是只允许在一端进行插入操作、而在另一端进行删除操作的线性表
- 堆(Heap)是计算机科学中一类特殊的数据结构的统称,其通常是一个可以被看做一棵完全二叉树的数组对象。
栈是一种后进先出的线性表(LIFO),栈顶是允许插入和删除的一端,栈底不允许任何操作。其包含的操作有:进栈和出栈。
队列是一种先进先出的线性表的线性表(FIFO),队头是允许删除的一端,队尾是允许插入的一端。
双向队列
双向队列,顾名思义就是队列头尾均可以操作的队列,它允许在容器头部快速插入和删除,C++中使用deque来表示。
优先队列
优先队列中,元素被赋予优先级,当删除元素时,具有最高优先级的元素最先删除,即具有最高级先出 (First In, Largest Out)的特点。
//升序队列,小顶堆
priority_queue ,greater> q;
//降序队列,大顶堆
priority_queue a; //写法一
priority_queue ,less>q; //写法二
堆是一棵具有特定性质的完全二叉树,它满足堆中所有结点大于等于(或小于等于)其孩子结点的基本特性。
Q:栈的溢出
A:栈溢出是指程序向栈中某个变量写入的字节数超过了这个变量本身所申请的字节数,因而导致栈中与其相邻的变量的值被改变。
原因:
1)局部数组过大。局部变量是存储在栈中的,当函数内部的数组过大时,有可能导致堆栈溢出。
2)递归调用层次太多。递归函数在运行时会执行压栈操作,当压栈次数太多时,也会导致堆栈溢出。
3)指针或数组越界。
Q:堆和栈的区别
A:
1)申请方式:栈由系统自动分配和管理,堆由程序员手动分配和管理。
2)效率:栈由系统分配,计算机底层对栈提供了一系列支持:分配专门的寄存器存储栈的地址,压栈和入栈有专门的指令执行,因此,其速度快,不会有内存碎片;堆由程序员分配,堆是由C/C++函数库提供的,机制复杂,需要一些列分配内存、合并内存和释放内存的算法,因此效率较低,可能由于操作不当产生内存碎片。
3)扩展方向:栈从高地址向低地址进行扩展,堆由低地址向高地址进行扩展。
4)程序局部变量是使用的栈空间,new/malloc动态申请的内存是堆空间;同时,函数调用时会进行形参和返回值的压栈出栈,也是用的栈空间。
- 串是由零个或多个字符组成的有限序列,又称字符串。
串这个数据结构本身没什么好讲的,而关于串的算法面试题很多,参见:https://github.com/CyC2018/CS-Notes/blob/master/notes/Leetcode%20%E9%A2%98%E8%A7%A3%20-%20%E5%AD%97%E7%AC%A6%E4%B8%B2.md
树是n个结点的有限集(n=0时,称为空树),在任意一颗非空树中:
- 有且仅有一个根结点(Root)
- 当n>1时,其余结点可分为m个互不相交的有限集合,其中每个集合本身又是一棵树,并且称为根的子树(Subtree)
1、树的度
树的结点包含一个数据元素及若干个指向其子树的分支,结点拥有的子树数量称为结点的度。
2、树结点间的关系
树中结点的关系包括:双亲、兄弟、孩子,下面用一张图简明表达:
3、树的层次
树中有深度和高度两种定义,深度定义是从上往下的,高度定义是从下往上的。(此处约定深度和高度均从1开始,空树为0)
二叉树(Binary tree)是n个结点的有限集合(该集合或为空集),由一个根节点和两棵互不相交、分别称为根节点的左子树和右子树的二叉树组成,二叉树中不存在度大于2的结点。
6.2.1 特殊二叉树
1、斜二叉树
所有结点都只有左子树的二叉树叫左斜树,所有结点都只有右子树的二叉树叫右斜树。
2、满二叉树
所有分支节点都存在左子树和右子树,并且所有叶子都在同一层上的二叉树。
3、完全二叉树
高度为h的二叉树,除了h层外其余层次的结点数均达到最大,且h层的所有结点都连续集中在最左边。
6.2.2 二叉树的重要性质
1、在二叉树的第i层,至多有个结点
2、深度为k的二叉树最多有个结点
3、对任何非空二叉树T,若表示叶结点的个数,是度为2的非叶节点的个数,那么两者满足关系
4、具有n个结点的完全二叉树高度为
6、完全二叉树的任意结点i,其父节点为[i/2],左孩子为2i,右孩子为2i+1。
6.2.3 二叉树的存储结构
1、顺序存储结构
用一维数组存储二叉树中的结点,并且结点的存储位置,也就是数组的下标要能体现结点之间的逻辑关系。
可以将其按完全二叉树来编号,仅是把不存在的结点设置为空,但会造成空间浪费。
2、链式存储结构
二叉树每个结点最多有两个孩子,所以为其设计一个数据域和两个指针域,称这样的链表为二叉链表。
定义:从根节点出发,按照某种次序依次访问二叉树中所有结点,使得每个结点被访问一次,且仅被访问一次。
1、前序遍历
若二叉树为空,则空操作返回,否则先访问根节点,然后前序遍历左子树,再前序遍历右子树,下图遍历顺序为:ABDGHCEIF
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
class Solution {
public:
//递归法
void dfs(TreeNode* root, vector res){
if (root) {
res.push_back(root->val);
dfs(root.left, res);
dfs(root.right, res);
}
}
vector preorderTraversal(TreeNode* root) {
vector res;
dfs(root, res);
return res;
}
/*-----------------------------------------------*/
//非递归
vector preorderTraversal(TreeNode* root) {
vector res;
stack st; //设置一个栈
st.push(root); //根结点入栈
while(!st.empty()){ //栈中有元素就一直循环
TreeNode* node = st.top();
st.pop(); //取结点,并出栈
if(!node) continue;
res.push_back(node->val); //添加进结果
st.push(node->right); //右孩子入栈(先进后出)
st.push(node->left); //左孩子入栈
}
return res;
}
};
2、中序遍历
若二叉树为空,则空操作返回,否则从根节点开始(并不访问),中序遍历左子树,然后访问根节点,最后中序遍历右子树,下图遍历顺序为:GDHBAEICF
class Solution {
public:
vector inorderTraversal(TreeNode* root) {
vector res;
stack st;
TreeNode* cur = root;
while(cur || !st.empty()){ //当指针存在或栈不为空时
while(cur){ //将指针指向最左边的叶子节点,一路上的所有结点均入栈
st.push(cur);
cur = cur->left;
}
TreeNode* node = st.top();
st.pop();
res.push_back(node->val); //取栈顶结点遍历
cur = node->right; //指针指向右孩子
}
return res;
}
};
3、后序遍历
若二叉树为空,则空操作返回,否则从左到右,先叶子后结点的方式遍历访问左右子树,最后访问根节点,,下图遍历顺序为:GHDBIEFCA
前序遍历为 root -> left -> right,后序遍历为 left -> right -> root。可以修改前序遍历成为 root -> right -> left,那么这个顺序就和后序遍历正好相反。
class Solution {
public:
vector postorderTraversal(TreeNode* root) {
vector res;
stack st;
st.push(root);
while(!st.empty()){
TreeNode* node = st.top();
st.pop();
if(!node) continue;
res.push_back(node->val);
st.push(node->left);
st.push(node->right);
}
reverse(res.begin(), res.end());
return res;
}
};
4、层序遍历
若二叉树为空,则空操作返回,否则从树的第一层,也就是根结点开始访问,从上而下逐层遍历,同一层中,按从左到右的顺序对结点逐个访问,遍历顺序为:ABCDEFGHI
注意:
1)第一次碰到结点打印出来——先序遍历
2)第二次碰到结点打印出来——中序遍历
3)第三次碰到结点打印出来——后序遍历
其中,前和中、中和后两对遍历序列都可以唯一确定这棵二叉树,二叉树遍历的核心问题:怎么将二维结构转换成一维线性序列
二叉搜索树(BST):根节点大于等于左子树所有节点,小于等于右子树所有节点。其具有如下特性:
有了二叉搜索树,当你要查找一个值,就不需要遍历整个序列或者说遍历整棵树了,可以根据当前遍历到的结点的值来确定搜索方向(剪枝),就可以使插入、搜索效率大大提高。
1、查找
从根结点开始,根据目标值大小,选择左/右子树查找,直到找到目标。
//查找
Position IterFind(ElementType X, BinTree BST)
{
while(BST){
if(X>BST->Data)
BST=BST->Right; //向右子树中移动,继续查找
else if(xData)
BST=BST->Left;
else
return BST;
}
return NULL;
}
2、插入
根据目标值,递归的寻找适合结点插入的位置,生成新节点并插入。
//插入
BinTree Insert(ElementType X,BinTree BST)
{
if(!BST){
//若原树为空(即BST不存在),生成并返回一个结点
BST=malloc(sizeof(struct TreeNode));
BST->Data=X;
BST->Left= BST->Right = NULL;
}else{
if (XData)
//递归插入左子树
BST->Left = Insert(X,BST->Left);
else if (X>BST->Data)
BST->Right=Insert(X,BST->Right);
}
return BST;
}
3、删除
先找到要删除的结点,然后进行如下判断:
若结点为叶子结点,直接删除;
若结点只有左子树或只有右子树,直接用左子树或右子树替换该结点;
若结点左右子树均存在,则找左子树中最大(最右)的结点(直接前驱)或右子树中最小(最左)的结点(直接后继)替换该结点。
//删除
BinTree Delete(ElementType X,BinTree BST)
{
Position Tmp;
//树为空
if(!BST) printf("要删除的元素未找到");
else if (XData)
//左子树递归删除
BST->Left = Delete(X,BST->Left);
else if (X>BST->Data)
//右子树递归删除
BST->Right = Delete(X,BST->Right);
else //找到了要删除的结点
if(BST->Left && BST->Right){
//如果被删除的结点有左右两个结点
Tmp = FindMin(BST->Right); //在右子树中找最小值或左子树中找最大值
BST->Data =Tmp->Data; //用该值覆盖要删除结点的值
BST->Right = Delete(BST->Data,BST->Right); //在删除结点的右子树中删除最小元素
} else {
//被删除的结点只有一个或无子结点
Tmp = BST;
if(!BST->Left) //有右子结点或无子结点
BST=BST->Right;
else if (!BST->Right)
BST=BST->Left;
free(Tmp);
}
return BST;
}
定义:平衡二叉树又称为AVL树,是一种特殊的二叉搜索树,其任意结点左右子树高度差的绝对值不超过1。
将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子,那么平衡二叉树上的所有结点的平衡因子只可能是-1、0和1。
引入AVL树的原因:如果插入的序列越有序,生成的二叉搜索树越像一个链表,查找的时间复杂度就接近O(n)了。为了避免这种情况,引入了平衡二叉树,即让树的结构看起来尽量“均匀”,左右子树的节点数尽量一样多。
判定二叉树是否平衡
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
class Solution { //使用递归判定二叉树是否平衡
private:
bool res = true;
int maxDepth(TreeNode* root){
if(root == nullptr) return 0;
int l = maxDepth(root->left);
int r = maxDepth(root->right);
if(abs(l-r) > 1) res = false;
return max(l, r) + 1;
}
public:
bool isBalanced(TreeNode* root) {
maxDepth(root);
return res;
}
};
1、定义
红黑树是一种二叉搜索树,但其在每个结点上增加了一个存储位表示结点的颜色,可以是Red或Black。
通过对任何一条从根到叶子的路径上各个结点着色方式的限制,红黑树确保没有一条路径会比其他路径长出两倍,因而是接近平衡的(弱平衡)。下图即为一颗红黑树:
红黑树在二叉搜索树的基础上增加了着色,并通过相关性质使得红黑树相对平衡,这些性质具体包括:
1)每个结点要么是红的要么是黑的;
2)根结点是黑的;
3)每个叶结点(叶结点即指树尾端NIL指针或NULL结点)都是黑的;
4)如果一个结点是红的,那么它的两个儿子都是黑的;
5)对于任意结点而言,其到树尾端NULL结点的每条路径都包含相同数目的黑结点。
正是红黑树的这5条性质,使一棵n个结点的红黑树始终保持了log n的高度,从而保证了红黑树的查找、插入、删除的时间复杂度最坏为O(log n)。
2、红黑树的旋转
红黑树的旋转是一种能保持二叉搜索树性质的搜索树局部操作。有左旋和右旋两种旋转,通过改变树中某些结点的颜色以及指针结构来保持对红黑树进行插入和删除操作后的红黑性质。
左旋:对某个结点x做左旋操作时,假设其右孩子为y而不是NULL:以x到y的链为“支轴”进行。使y成为该子树新的根结点,x成为y的左孩子,y的左孩子成为x的右孩子。
右旋:对某个结点x做右旋操作时,假设其左孩子为y而不是NULL:以x到y的链为“支轴”进行。使y成为该子树新的根结点,x成为y的右孩子,y的右孩子成为x的左孩子。
3、红黑树相较AVL树的优点
AVL 树是高度平衡的,频繁的插入和删除,会引起频繁的rebalance,导致效率下降;红黑树高度弱平衡,算是一种折中,插入最多两次旋转,删除最多三次旋转。红黑树查找、插入、删除的性能都是O(logn),且性能稳定,所以STL里面很多结构包括map底层实现都是使用的红黑树。
1、概念
结点的带权路径:该结点到树根之间的路径长度与结点上权的乘积
树的带权路径:树中所有结点的带权路径长度之和
哈夫曼树:带权路径长度(Weighted Path Length, WPL)最小的二叉树,称为哈夫曼树,也叫最优二叉树
如上图所示,二叉树a的WPL为:
二叉树b的WPL为:
这意味着,如果此时有10000个学生需要判断五级分制成绩,用二叉树a需要做31500次比较,用二叉树b只需要做22000次比较。
2、哈夫曼树的构造过程
哈夫曼树的构造算法步骤如下:
对于刚才的五级分制问题,构造出的哈夫曼树如下图所示:
可以看到,其WPL=205,此时才是最优的哈夫曼树。
3、哈夫曼树的特点
1)没有度为1的结点;
2)n个叶子结点的哈弗曼树共有2n-1个结点;
3)对同一组权值存在不同构的哈夫曼树。
4、哈夫曼编码
哈夫曼编码是哈夫曼树的一种应用,广泛用于数据文件压缩。其根据字符使用频率进行编码,以最大化节省字符的存储空间。
具体来说,哈夫曼编码算法用字符(结点)在文件中出现的频率(权值)来建立哈夫曼树,路径上使用0,1编码表示该字符。下面举一个例子来直观表达:
假设有A、B、C、D、E五个字符,出现的频率分别为5、4、3、2、1,那么我们根据哈夫曼树的构造方法,即可构造出如下哈夫曼树:
构造过程中,我们默认左路径用0表示,右路径用1表示,那么最终5个字符的哈夫曼编码为:A->11,B->10,C->00,D->011,E->010
6.8.1 B树
背景:通常来说,数据库的索引会采用哈希表或者树结构来存储,这是因为树的查询效率高且可以保持有序。
矛盾:但是我们并不采用二叉搜索树来存储,是因为在进行索引查询时计算机需要多次进行IO操作,每次IO操作只能加载一个磁盘页(对应一个结点),而二叉搜索树中进行查询时最坏情况下需要进行和树的高度一样次数的IO操作,当结点海量时,二叉搜索树的高度极高,会大幅降低效率。
解决方案:因此,为了减少IO次数,我们把原本瘦高的树结构设计成矮胖的样式,提出了多路平衡搜索树(即B树),一棵m阶的B树具有如下特征:
下面,我们举个例子来直观了解B树的查找过程:
假设我们要搜索5这个元素,第一次IO加载入【9】这个结点,然后在内存中将5和结点元素进行比较;第二次IO加载入【2 6】这个结点,同样在内存中进行比较定位;第三次IO加载入【3 5】这个结点,定位到5这个元素。
可以看出,当单一结点元素较多时,需要在内存中进行多次比较,但相比于磁盘IO的速度可以忽略不计,所以,只要树的高度足够低,IO次数够少,就可以提升查找性能。
B树的插入和删除比较麻烦,但可以实现自平衡,具体可参考如下链接:https://www.jianshu.com/p/8b653423c586
6.8.2 B+树
B+树是B树的一种变体,有着比B树更高的查询性能。一棵m阶的B+树在B树基础上多出如下几个特征:
下面,我们举个例子来直观了解B+树:
我们可以看到,每个父亲结点的元素都会出现在子结点中,并且是子结点中最大/最小的元素;叶子结点包含了所有元素,并且每个叶子结点都有指向下一个叶子结点的指针,形成了一个有序链表。
注意:
1、B+树的单行查询
B+树的单行查询与B树类似,区别在于:
2、B+树的范围查询
在B树中,我们如果要做范围查询,只能依靠中序遍历,假设我们要查找[3,11]的元素,首先我们自顶向下查到下限3,然后中序遍历到6、8、9、11,遍历才能结束。而在B+树中,我们只需要先查到下限3,然后在链表上做遍历即可:
综上所述,B+树相对B树的优势有三点:
至于B+树的插入和删除,则与B树类似。
6.8.3 B*树
B*树是B+树的变体,在B+树的基础上(所有的叶子结点中包含了全部关键字的信息,及指向含有这些关键字记录的指针),B*树中中间结点再增加指向兄弟的指针;B*树定义了非叶子结点关键字个数至少为(2/3)*m,即块的最低使用率为2/3(代替B+树的1/2)。给出了一个简单实例,如下图所示:
特点:B*树中插入一个元素时,当所在结点满了,如果它的下一个兄弟结点未满,那么将一部分数据移到兄弟结点中,再在原结点插入关键字,最后修改父结点中兄弟结点的关键字(因为兄弟结点的关键字范围改变了)。因此,B*树分配新结点的概率比B+树要低,空间使用率更高。
Q:知道树的前序和中序遍历结果(或者中序和后序遍历结果),如何复原二叉树?
A:前序/后序可知道根节点,中序可划分左右子树,根据这个原则即可复原二叉树。
Q:
A:
- 图是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为:G(V,E),其中,G表示一个图,V是图G中顶点的集合,E是图G中边的集合。
1、无向图
顶点之间没有方向,称这条边为无向边,全部由无向边构成图称为无向图(Undirected Graph)。
无向图中,如果任意两个顶点之间都存在边,则称该图为无向完全图,含有n个顶点的无向完全图有条边。
2、有向图
顶点之间有方向,称这条边为有向边,全部由有向边构成图称为无向图(Directed Graph)。
有向图中,如果任意两个顶点间都存在方向互为相反的两条弧,则称为有向完全图。含有n个顶点的有向完全图有n(n−1)条边。
3、连通图
无向图中,如果两个顶点之间有路径,说明两顶点是连通的,如果对于图中任意两个顶点都是连通的,则称该无向图是连通图。
4、连通分量
无向图中的极大连通子图称为连通分量。注意连通分量的概念,它强调:
如图1中,其连通分量包括图2和图3
有向图中,如果对于每一对vi,vj(vi不等于vj),从vi到vj都存在路径,称其为强连通图。
5、总结
图的结构比较复杂,任意两个顶点之间都可能存在联系,所以不可能用简单的顺序存储结构来表示,所以用下面的五种方法来存储图。
7.2.1 邻接矩阵
邻接矩阵用两个数组来表示图,一个一维数组存储图中顶点的信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。
如上图所示,顶点x和顶点y之间有边关联,那么矩阵中的元素A[x][y]与A[y][x]的值就是1,否则为0。像这样表达图中顶点关联关系的矩阵,就叫做邻接矩阵。
注意:
邻接矩阵的优点就是简单直观,可以快速查到一个顶点和另一顶点之间的关联关系;缺点就是占用了太多空间。
7.2.2 邻接表和逆邻接表
为了解决邻接矩阵占用空间的问题,人们想到了另一种图的表示方法:邻接表。
在邻接表中,图的每一个顶点都是一个链表的头结点,其后连接着该顶点能够直接达到的相邻顶点。
要想查出顶点x能够到达的所有相邻顶点,从顶点x向后的所有链表结点,就是顶点x能到达的相邻顶点;但是,如果要查找哪些点能到达顶点x,就只能去遍历每个顶点所在链表,看其链表中是否包含顶点x,这种逆向查找的方式略显麻烦,因此提出了逆邻接表的结构:
逆邻接表每一个顶点作为链表的头节点,后继节点所存储的是能够直接达到该顶点的相邻顶点。
因此,我们可以根据实际需求,选择使用邻接表还是逆邻接表。
7.2.3 十字链表
十字链表把邻接表和逆邻接表结合了起来,优化之后的十字链表,是下面这个样子:
图中蓝色为顶点,绿色为边,每一条带有蓝色箭头的链表,存储着从顶点出发的边;每一条带有橙色箭头的链表,存储着进入顶点的边。
7.2.4 邻接多重表
略
7.2.5 边集数组
略
从图中某一顶点出发访问图中其余顶点,且使每一个顶点仅被访问一次,该过程叫图的遍历。
7.3.1 深度优先遍历(Depth First Search, DFS)
深度优先遍历类似于树的前序遍历,其遍历整个图的方法是:
1、DFS的基本结构
在程序实现 DFS 时需要考虑以下问题:
//非递归的DFS用栈来实现,递归式的DFS基本结构如下
void dfs(int[][] grid, int r, int c) {
// 判断 base case,不满足条件立即返回
if (!inArea(grid, r, c)) return;
// 判断是否遍历过了
if (grid[r][c] != 1) return;
// 将格子标记为「已遍历过」
grid[r][c] = 1;
// 访问邻接点
dfs(grid, r - 1, c);
dfs(grid, r + 1, c);
dfs(grid, r, c - 1);
dfs(grid, r, c + 1);
}
2、Backtracking
Backtracking(回溯)属于 DFS。
因为 Backtracking 不是立即返回,而要继续求解,因此在程序实现时,需要注意对元素的标记问题:
string cur;
void backTracking(string digits){
if(digits.size() == 0){
res.push_back(cur);
}else{
char num = digits[0];
string letter = mp[num];
for(int i = 0; i < letter.size(); i++){
cur.push_back(letter[i]);
backTracking(digits.substr(1));
cur.pop_back();
}
}
return;
}
7.3.2 广度优先遍历(Breadth First Search, BFS)
广度优先遍历类似于树的层序遍历,先将某一结点入队,出队时将所有与其相连的结点入队,以此类推。
在程序实现 BFS 时需要考虑以下问题:
class Solution {
public:
int shortestPathBinaryMatrix(vector>& grid) {
int ans = 0;
queue myQ; // BFS一般通过队列方式解决
int M = grid.size();
int N = grid[0].size();
// 先判断边界条件,很明显,这两种情况下都是不能到达终点的。
if (grid[0][0] == 1 || grid[M - 1][N - 1] == 1) return -1;
// 备忘录,记录已经走过的结点
vector> mem(M, vector(N, 0));
//塞入初始结点
myQ.push({0, 0});
mem[0][0] = 1;
// 以下是标准BFS的写法
while (!myQ.empty()) {
int size = myQ.size();
for (int i = 0; i < size; i++) {
Node currentNode = myQ.front();
int x = currentNode.x;
int y = currentNode.y;
// 判断是否满足退出的条件
if (x == (N - 1) && y == (M - 1)) return (ans + 1);
// 下一个节点所有可能情况
vector nextNodes = {
{x + 1, y}, {x - 1, y}, {x + 1, y - 1}, {x + 1, y + 1},
{x, y + 1}, {x, y - 1}, {x - 1, y - 1}, {x - 1, y + 1}};
for (auto& n : nextNodes) {
// 过滤条件1: 边界检查
if (n.x < 0 || n.x >= N || n.y < 0 || n.y >= M) continue;
// 过滤条件2:备忘录检查
if (mem[n.y][n.x] == 1) continue;
// 过滤条件3:题目中的要求
if (grid[n.y][n.x] == 1) continue;
// 通过过滤筛选,加入队列!
mem[n.y][n.x] = 1;
myQ.push(n);
}
myQ.pop();
}
ans++;
}
return -1;
}
};
7.3.3 总结
深度优先搜索用栈(stack)来实现,整个过程可以想象成一个倒立的树形:
- 把根节点压入栈中。
- 每次从栈中弹出一个元素,搜索所有在它下一级的元素,把这些元素压入栈中。并把这个元素记为它下一级元素的前驱。
- 找到所要找的元素时结束程序,如果遍历整个树还没有找到,结束程序。
广度优先搜索使用队列(queue)来实现,整个过程也可以看做一个倒立的树形:
- 把根节点放到队列的末尾。
- 每次从队列的头部取出一个元素,查看这个元素所有的下一级元素,把它们放到队列的末尾。并把这个元素记为它下一级元素的前驱。
- 找到所要找的元素时结束程序,如果遍历整个树还没有找到,结束程序。
几句话总结:
DFS
是有优势的,DFS
不需要保存搜索过程中的状态,而BFS
在搜索过程中需要保存搜索过的状态,而且一般情况需要一个队列来记录。最短路径是两顶点之间经过的边上权值之和最少的路径,并且成第一个顶点是源点,最后一个顶点是终点。
7.4.1 无权图的单源最短路径算法
在BFS的基础上做改动,首先定义dist[W]为S到W最短距离,path[W]为S到W路上经过的某顶点,初始化dist[S]=0,dist[W]=-1。
算法流程:
- 源点S入队;
- 进入while循环,若队列不为空,则从队列中弹出一个结点V;
- 遍历结点V的邻接点W,若邻接点W没被访问过(dist[W]=-1),则dist[W] = dist[V]+1,且path[W]=V;
- 再将该邻接点压入队列,不断循环直至遍历完整个图。
7.4.2 Dijkstra算法
迪杰斯特拉算法是一种有权图的单源最短路径算法。
主要思想:设有两个顶点集合S和T,S中存放已找到最短路径的顶点,T存放剩余顶点。初始时S只包含源点 ,然后不断从T中选取到 路径长度最短的顶点 并入集合S。集合S每并入一个新顶点,都要修改源点 到集合T中其他顶点的最短路径长度。不断重复此过程,直到T中顶点都并入S为止。
为了实现这一算法,应初始化三个数组:
表示 到 的最短路径长度(初态为与源点相邻的设为权值,其余为无穷)
表示 到 的最短路径中,的前一个顶点(初态为与源点相邻设为0,否则为-1)
表示 是否并入集合S了,并入为1,未并入为0
则迪杰斯特拉算法的伪代码描述如下:
void Dijkstra( Vertex s ){
while(1){
V=未收录的顶点中dist最小者;
if(找不到这样的V) break;
collected[V] = true;
for(V的每个邻接点W){
if(collected[W] = false){
if(dist[V]+E < dist[W]){
dist[W] = dist[V]+E;
path[W] = V;
}
}
}
}
}
注意:该算法不可解决负边的问题,时间复杂度为或者,路径的打印用堆栈。
7.4.3 Floyd算法
弗洛伊德算法是一种多源最短路径算法。
主要思想:设置两个矩阵A和Path,初始时将邻接矩阵直赋值给A,Path中元素全赋为-1。以顶点k为中间顶点,,对图中所有顶点对 {i, j} 进行检测,若 则将 的值修改为 的值, 改为k,否则什么都不做。
则弗洛伊德算法的伪代码如下:
void Floyd(){
//矩阵A和Path的初始化
for(i=0;iA[i][k]+A[k][j]){
A[i][j]=A[i][k]+A[k][j];
Path[i][j]=k;
}
}
}
}
}
注意:路径的打印用递归。
生成树:一个连通图的生成树是指一个连通子图,它含有图中全部n个顶点,但只有足以构成一棵树的n-1条边。一棵有n个顶点的生成树有且仅有n-1条边,如果生成树中再添加一条边,则必定成环。
最小生成树:在连通网的所有生成树中,所有边的代价和最小的生成树,称为最小生成树。
7.5.1 Kruskal算法
此算法可以称为“加边法”,初始最小生成树边数为0,每迭代一次就选择一条满足条件的最小代价边,加入到最小生成树的边集合里。
- 把图中的所有边按代价从小到大排序;
- 把图中的n个顶点看成独立的n棵树组成的森林;
- 按权值从小到大选择边,所选的边连接的两个顶点 应属于两颗不同的树,则这条边成为最小生成树的一条边,并将这两棵树合并成为一棵树;
- 重复上述步骤,直到所有顶点都在一颗树内或者有n-1条边为止。
7.5.2 Prim算法
此算法可以称为“加点法”,每次迭代选择相对于树来说代价最小的边对应的点,加入到最小生成树中。算法从某一个顶点s开始,逐渐扩大覆盖整个连通网的所有顶点。
- 图的所有顶点集合为V,初始时令集合u={s},v=V−u;
- 在两个集合 u,v 能够组成的边中,选择一条代价最小的边 ,加入到最小生成树中,并把 并入到集合 u 中。
- 重复上述步骤,直到最小生成树有n-1条边或者n个顶点为止。
由于不断向集合u中加点,所以最小代价边必须同步更新;需要建立一个辅助数组closedge,用来维护集合v中每个顶点与集合u中最小代价边信息:
struct {
char vertexData //表示v中顶点
unsigned int lowestCost //该顶点到u的最小代价
} closEdge[vexCounts]
拓扑序列:如果图中从V到W有一条有向路径,则V一定排在W之前,满足此条件的顶点序列成为一个拓扑序列。
获得一个拓扑序列的过程就称为拓扑排序。拓扑排序广泛应用在AOV(Activity on Vertex)网络和DAG(Directed Acyclic Graph)有向无环图中。个人理解:当要完成一件事时,拓扑排序就是给出完成事件的顺序,比如要先完成任务一,才能去完成任务二,以此类推。
简单来说,拓扑排序过程其实就是每次从图中找到入度为0的顶点打印出来,伪代码如下:
void TopoSort(){
for(图中每个顶点V){
if(Indegree(V) == 0) Enqueue(V,Q);
}
while(!isEmpty(Q)){
V=Dequeue(Q);
输出V;
count++;
for(V的每个邻接点W){
if(--Indegree(W)==0) Enqueue(W,Q);
}
}
if(count != |V|) Error("图中有回路");
}
下面给出一个例子:
Q:
A:
- 定义:哈希表(Hash table),是根据关键值(Key value)而直接进行访问的数据结构。它通过将关键值映射到表中一个位置来访问数据记录,以加快查找的速度。这个映射函数叫做哈希函数,存放数据记录的数组叫做哈希表。
- 概括:哈希函数就是根据key计算出数据记录的存储位置,而哈希表是基于哈希函数建立的一种查找表。
哈希函数能使对一个数据序列的访问过程更加迅速有效,通过哈希函数,数据元素将被更快地定位。实际工作中需视不同的情况采用不同的哈希函数,通常考虑的因素有:
具体来说,主要包括如下几种哈希函数:
1、直接寻址法
取关键字或关键字的某个线性函数值为散列地址,即 或 。若 H(key) 已经有值了,就往下一个地址找,直到H(key)中没有值了,就放进去。
2、数字分析法
数字分析法就是找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址。
3、平方取中法
如果关键字的每一位都有某些数字重复出现频率很高的现象,可以先求关键字的平方值,通过平方扩大差异,然后按需要取平方值的中间几位作为哈希地址。
4、折叠法
如果数字的位数很多,可以将数字分割为几个部分,取他们的叠加和作为hash地址
5、随机数法
选择一随机函数,取关键字的随机值作为散列地址。
6、除留余数法
取关键字被某个不大于哈希表表长m的数p除后所得的余数为散列地址。即 H(key) = key MOD p,p<=m。不仅可以对关键字直接取模,也可在折叠、平方取中等运算之后取模。对p的选择很重要,一般取素数或m,若p选的不好,容易产生同义词。
不管hash函数设计的如何巧妙,总会有特殊的key导致hash冲突,特别是对动态查找表来说。hash函数解决冲突的方法有以下几个常用的方法:
1、开放寻址法
Hi=(H(key) + di) MOD m,i=1,2,…,k(k<=m-1),其中H(key)为哈希函数,m为哈希表表长,di为增量序列,可有下列三种取法:
2、再哈希法
,其中 是不同的哈希函数,即在同义词产生地址冲突时计算另一个散列函数地址,直到冲突不再发生,这种方法不易产生“聚集”,但增加了计算时间。
3、公共溢出区法
建立一个特殊存储空间,专门存放冲突的数据。此种方法适用于数据和冲突较少的情况。
4、链地址法
产生hash冲突后在存储数据后面加一个指针,指向后面冲突的数据:
查找过程和造表过程一致,假设采用开放定址法处理冲突,则查找过程为:
- 对于给定的key,计算哈希地址index = H(key)
- 如果数组arr[index]的值为空,则查找不成功;如果数组arr[index]== key,则查找成功
- 否则,使用冲突解决方法求下一个地址,直到arr[index]== key或者 arr[index]==null
产生冲突后的查找仍然是给定值与关键码进行比较的过程。所以,对哈希表查找效率的量度,依然用平均查找长度来衡量。查找过程中,关键码的比较次数,取决于产生冲突的多少,产生的冲突少,查找效率就高,产生的冲突多,查找效率就低。因此,影响产生冲突多少的因素,也就是影响查找效率的因素。影响产生冲突多少有以下三个因素:
其中,装填因子定义为 填入表中的元素个数/哈希表的长度,哈希表的平均查找长度是装填因子α的函数,只是不同处理冲突的方法有不同的函数。
了解了hash基本定义,就不能不提到一些著名的hash算法,MD5 和 SHA-1 可以说是目前应用最广泛的Hash算法,而它们都是以 MD4 为基础设计的。Hash算法在信息安全方面的应用主要体现在以下的3个方面:
Q:
A:
- 定义:查找就是根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素。
- 静态查找表:只作查找操作的查找表。
- 动态查找表:在查找过程中同时插入查找表中不存在的数据元素,或从查找表中删除已经存在的某个元素。
9.1.1 顺序查找
定义:顺序查找又称线性查找,其查找过程就是从表中第一个记录开始,逐个进行记录的关键字和给定值的比较。
顺序查找的优化:设置一个哨兵a[0]=key,从末尾往前查找;如果查找过程中a[i]=key,则返回i值,否则最终一定在a[0]处返回,说明a[1]到a[n]中没有关键字key,查找失败。这种方法在数据量大的时候可以极大提高效率。
对于顺序查找来说,其平均时间复杂度为O(n)。
9.1.2 有序表查找
1、二分查找
二分查找也叫折半查找,前提是线性表中的记录必须是关键码有序(通常从小到大有序),线性表必须采用顺序存储,这种折半特性的算法时间复杂度为 O(logN)。
折半查找的思想:在有序表中,若给定值小于中间记录的关键字,则在中间记录的左半边继续查找,若给定的值大于中间记录的关键字,则在中间记录的右半边继续查找,不断重复,直到查找成功,或查找区域无记录,查找失败为止。
通常来说,二分查找的代码框架如下:
int binarySearch(int[] nums, int key) {
int l = 0, h = nums.length - 1;
while (l <= h) {
int m = l + (h - l) / 2;
if (nums[m] == key) {
return m;
} else if (nums[m] > key) {
h = m - 1;
} else {
l = m + 1;
}
}
return -1;
}
注意:
int binarySearch(int[] nums, int key) {
int l = 0, h = nums.length - 1;
while (l < h) {
int m = l + (h - l) / 2;
if (nums[m] >= key) {
h = m;
} else {
l = m + 1;
}
}
return l;
}
该实现和正常实现有以下不同:
在 nums[m] >= key的情况下,可以推导出最左key位于 [l, m] 区间中,这是一个闭区间。h的赋值表达式为h = m,因为m位置也可能是解。
在 h 的赋值表达式为 h = m 的情况下,如果循环条件为 l <= h,那么会出现循环无法退出的情况,因此循环条件只能是 l < h。
当循环体退出时,不表示没有查找到 key,因此最后返回的结果不应该为 -1。为了验证有没有查找到,需要在调用端判断一下返回位置上的值和 key 是否相等。
2、插值查找
Q:
A:
内排序与外排序:
根据排序过程中,待排序记录是否全部被放置在内存中,排序分为:内排序和外排序
排序的稳定性:
假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
算法步骤:
- 从前往后比较相邻的两个元素,如果第一个比第二个大,就交换他们两个,进行length-1轮;
- 上一步做完i轮后,最后的i元素会是有序且最大的数。针对除最后i个外的所有的元素重复以上的步骤直到没有任何一对数字需要比较。
算法代码:
void BubbleSort(int* arr, int length) {
//外层是比较次数,总共要经过 N-1 轮比较
for (int i = 1; i < length; i++) {
//设定一个标记,若为true,则表示此次循环没有进行交换,也就是待排序列已经有序,排序已经完成
bool flag = true;
for (int j = 0; j < length - i; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr[j], arr[j + 1]);
flag = false;
}
}
if (flag) break;
}
}
算法步骤:
- 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置;
- 再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾;
- 重复第二步,直到所有元素均排序完毕。
算法代码:
void SelectionSort(int* arr, int length) {
for (int i = 0; i < length-1; i++) {
// 记录目前能找到的最小值元素的下标
int min = i;
// 每轮需要从i后第一个数字开始比到末尾
for (int j = i+1; j < length; j++) {
if (arr[j] < arr[min]) min = j;
}
// 将找到的最小值和i位置所在的值进行交换
if (i != min) swap(arr[i], arr[min]);
}
}
算法步骤:
- 将第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列;
- 从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面)。
算法代码:
void InsertionSort(int* arr, int length) {
for (int i = 1; i < length; i++) {
// 记录要插入的数据
int tmp = arr[i];
// 从有序序列最右边的开始比较,找到比arr[i]小的数(比大的往右移动)
int j = i - 1;
while (j >= 0 && tmp < arr[j]) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = tmp;
}
}
利用插入排序的思想,考虑到插入排序在序列基本有序且数量较少时性能较高,因此先对序列进行逻辑上的分组然后再进行插入排序。
算法步骤:
- 设定初始增量t;
- 每趟排序,根据对应的增量 t,将待排序列分割成若干长度为 m 的子序列,分别对各子序列进行直接插入排序;
- 随后减少增量,增加分组,继续对每个子序列进行插入排序,直到增量为1,整个序列作为一个子序列来处理,子序列长度即为整个序列的长度。
算法代码:
void ShellSort(int* arr, int length) {
for (int gap = length / 2; gap > 0; gap /= 2) {
for (int i = gap; i < length; i++) {
// 记录要插入的数据
int tmp = arr[i];
// 从有序子序列最右边的开始比较,找到比arr[i-gap]小的数(比大的往右移动)
int j = i - gap;
while (j >=0 && tmp < arr[j]) {
arr[j + gap] = arr[j];
j -= gap;
}
arr[j + gap] = tmp;
}
}
}
使用分治思想,将原始序列分为两部分分别排序,然后合并,重点在于合并(治)过程。
算法步骤:
- 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
- 设定两个指针,最初位置分别为两个已经排序序列的起始位置;
- 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动该指针到下一位置;
- 重复步骤 3 直到某一指针达到序列尾;
- 将另一序列剩下的所有元素直接复制到合并序列尾。(这些都是治的过程)
算法代码:
void merge(int* arr, int L, int M, int R) {
//申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
int* tmp = new int[R - L + 1];
int i = 0;
//设定两个指针,最初位置分别为两个已经排序序列的起始位置
int pFirst = L;
int pRight = M + 1;
//比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动该指针到下一位置,重复该步骤直到某一指针达到序列尾
while (pFirst <= M && pRight <= R) {
tmp[i++] = arr[pFirst] < arr[pRight] ? arr[pFirst++] : arr[pRight++];
}
//将另一序列剩下的所有元素直接复制到合并序列尾
while (pFirst <= M) {
tmp[i++] = arr[pFirst++];
}
while (pRight <= R) {
tmp[i++] = arr[pRight++];
}
//将临时数组的数据存入原数组
for (int j = 0; j < (R - L + 1); j++) {
arr[L + j] = tmp[j];
}
}
void MergeSort(int* arr, int L, int R) {
if (L == R) {
return;
}
int mid = (L + R) / 2;
MergeSort(arr, L, mid);
MergeSort(arr, mid + 1, R);
merge(arr, L, mid, R);
}
void MergeSort(int* arr, int length) {
if (arr == nullptr || length<2) {
return;
}
MergeSort(arr, 0, length - 1);
}
与归并排序类似,也使用分治思想。
算法步骤:
- 从数列中挑出一个元素,称为 “基准”(pivot),一般选择最后一个元素;
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边);
- 在这个分区退出之后,该基准就处于数列的中间位置,这个称为分区(partition)操作;
- 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
算法代码:
int partition(int* arr, int L, int R) {
//记录基准的值
int pivot = arr[L];
while (L < R) {
//从右往左找到小于基准的值的索引
while (L < R && pivot <= arr[R]) R--;
//将该值与左指针所指位置的值交换
if (L < R) arr[L++] = arr[R];
//从左往右找到大于基准的值
while (L < R && pivot >= arr[L]) L++;
//将该值与右指针所指位置的值交换
if (L < R) arr[R--] = arr[L];
}
//将左右指针重叠位置(索引)替换为基准值,此时该指针左边全小于基准,右边全大于基准
arr[L] = pivot;
//返回分区位置索引
return L;
}
void QuickSort(int* arr, int L, int R) {
if (L >= R) return;
int M = partition(arr, L, R);
QuickSort(arr, L, M - 1);
QuickSort(arr, M + 1, R);
}
void QuickSort(int* arr, int length) {
if (arr == nullptr || length<2) {
return;
}
QuickSort(arr, 0, length - 1);
}
首先,我们列出堆的基本形式:
该数组从逻辑上讲就是一个堆结构,我们用简单的公式来描述一下堆的定义就是:
大顶堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]
小顶堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]
算法步骤:
- 将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点;
- 将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;
- 然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值,继续交换堆顶元素与当前末尾元素;
- 反复执行调整+交换步骤,直到整个序列有序。
算法代码:
void heapify(int* arr, int i, int length) {
//找到该节点左右两个子节点的索引
int left = 2 * i + 1;
int right = 2 * i + 2;
//记录值最大节点的索引
int largest = i;
//如果左子节点大于该节点,则将值最大索引改为左子节点索引
if (left < length && arr[left] > arr[largest]) {
largest = left;
}
//如果右子节点大于该节点,则将值最大索引改为右子节点索引
if (right < length && arr[right] > arr[largest]) {
largest = right;
}
//如果值最大索引被改动过,则替换两个值,并对替换后的子节点也进行最大堆调整
if (largest != i) {
swap(arr[largest], arr[i]);
heapify(arr, largest, length);
}
}
void HeapSort(int* arr, int length) {
//1.构建最大堆
for (int i = length / 2 - 1; i >= 0; i--) {
//从第一个非叶子结点开始,从下至上,从右至左调整结构
heapify(arr, i, length);
}
//2.交换堆顶元素与末尾元素+调整剩余堆结构
for (int j = length - 1; j > 0; j--) {
swap(arr[0], arr[j]);
heapify(arr, 0, j);
}
}
算法步骤:
- 花O(n)的时间扫描一下整个序列 A,获取最小值 min 和最大值 max;
- 开辟一块新的空间创建新的数组 B,长度为 ( max - min + 1) ;
- 数组 B 中 index 的元素记录的值是 A 中某元素出现的次数;
- 最后输出目标整数序列,具体的逻辑是遍历数组 B,输出相应元素以及对应的个数。
算法代码:
void CountSort(int* arr, int length) {
int max = arr[0];
int lastIdx = 0;
//1.找到数组中的最大值
for (int i = 1; i < length; i++) {
max = arr[i] > max ? arr[i] : max;
}
//2.开辟一块空间,将数组中数字出现次数记录入空间内
int* sortArr = new int[max + 1]();
for (int j = 0; j < length; j++) {
sortArr[arr[j]]++;
}
//3.把空间内的值输入到原数组中
for (int k = 0; k < max + 1; k++) {
while (sortArr[k] > 0) {
arr[lastIdx++] = k;
sortArr[k]--;
}
}
}
桶排序是: 桶思想排序 + 一个普通的排序(常用快速排序)。
算法步骤:
- 找到数组中最大最小值,以此获得桶的个数((maxValue - minValue) / bucketSize + 1);
- 将数组数据映射入相应桶中;
- 对每个桶进行排序,并将排序后的数据插入原数组中。
算法代码:
void BucketSort(int* arr, int length, int bucketSize) {
if (length < 2) return;
// 找到数组中最大最小值
int minValue = arr[0], maxValue = arr[0];
for (int i = 0; i < length; i++) {
minValue = arr[i] < minValue ? arr[i] : minValue;
maxValue = arr[i] > maxValue ? arr[i] : maxValue;
}
//得到桶的个数
int bucketCnt = (maxValue - minValue) / bucketSize + 1;
vector> buckets(bucketCnt);
// 利用映射函数将数据分配到各个桶中
for (int j = 0; j < length; j++) {
int index = (arr[j] - minValue) / bucketSize;
buckets[index].push_back(arr[j]);
}
int arrIdx = 0;
for (auto bucket : buckets) {
if (bucket.size() <= 0) continue;
//对每个桶进行排序(此处采用插入排序)
int* tmpArr = new int[bucket.size()]();
copy(bucket.begin(), bucket.end(), tmpArr);
InsertionSort(tmpArr, bucket.size());
//将桶内元素写入原数组
for (int t = 0; t < bucket.size(); t++) {
cout << tmpArr[t] << endl;
arr[arrIdx++] = tmpArr[t];
}
}
}
算法步骤:
- 取得数组中的最大数,并取得位数,将所有待比较数值(正整数)统一为同样的位数长度,位数较短的在前面补零;
- 从最低位开始,依次进行计数排序;
- 从最低位排序一直到最高位排序完成以后, 数列就变成一个有序序列。
算法代码:
//拿到传入数的位数
int getRadixCount(int count) {
int num = 1;
if (count / 10 >0) {
num++;
}
return num;
}
void RadixSort(int* arr, int length) {
int max = arr[0];
for (int i = 1; i< length; i++) {
max = arr[i]>max ? arr[i] : max;
}
int radixCount = getRadixCount(max);
int mod = 10, dev = 1;
//遍历和最大值位数相等的次数(计数排序)
for (int i = 0; i < radixCount; i++, mod *= 10, dev *= 10) {
// 考虑负数的情况,这里扩展一倍队列数,其中 [0-9]对应负数,[10-19]对应正数 (bucket + 10)
vector> counter(mod * 2);
for (int j = 0; j < length; j++) {
int bucket = ((arr[j] % mod) / dev) + mod;
counter[bucket].push_back(arr[j]);
}
int pos = 0;
for (auto bucket : counter) {
for (int value : bucket) {
arr[pos++] = value;
}
}
}
}
Q:
A: