文章目录
一、前言
算法与数据结构是程序的精髓与灵魂。所谓数据结构,就是数据在内存中的存储方式。所谓算法,是在数据结构的基础之上,针对某一特定问题产生了解决思路和方法。因此算法与数据结构中,数据结构是基础,算法是在基础之上的解决方案。
二、数据集合及其结构
1. 数据类型概念
在计算机的数据输入,输出和处理过程中,数据常常以集合的方式进行。许多基础的数据类型都和对象的集合有关,数据类型的值就是一组对象的集合,并通过添加,删除,访问等方式对集合进行操作。
Q1. 什么是数据,数据元素,数据对象 ?
① 数据是信息的载体,是对客观事物的符号表示,是输入到计算机中被程序识别和处理的集合。
② 数据元素是数据的基本单位,一个数据元素由若干个数据项组成。
③ 数据对象是数据元素的集合。
Q2. 数据集合的分类 ?
数据集合按不同方式可以分为:逻辑结构和存储结构两类。逻辑结构表示数据与数据之间的联系被称为数据的逻辑。存储结构表示数据在计算机存储空间的存放形式。
① 逻辑结构包括线性结构,集合结构,树形结构和图(网状)结构。
2. 栈/队列
栈是一种后进先出(LIFO
)策略的集合类型,在栈中,元素的处理顺序和它们被压入的顺序正好相反,因此,在应用程序中使用栈迭代器的一个典型原因是在用集合保存元素,同时颠倒它们的相对顺序。
队列是一种先入先出(FIFO
)策略的集合类型,在应用程序中,使用队列的主要原因是在用集合保存元素的同时保存它们的相对顺序,是它们入队顺序和出队顺序相同。
2.1 STL中的栈和队列
在STL中,stack
表示栈,queue
表示队列。
(1).stack
stack
是通过序列容器来实现的。stack
主要有以下几个特点:
① stack
严格遵循后进先出(LIFO)原则,因此stack不提供元素的任何迭代器操作,不会向外部提供可用的前向或后向迭代器类型。
② stack
只能通过top从栈顶获取和删除数据,不能遍历,不支持随机存储。
③ stack
的常用操作:
使用时注意包含头文件 #include <stack>
s.empty() //如果栈为空返回true,否则返回false
s.size() //返回栈中元素的个数
s.pop() //删除栈顶元素但不返回其值
s.top() //返回栈顶的元素,但不删除该元素
s.push(X) //在栈顶压入新元素 ,参数X为要压入的元素
(2).queue 与 priority_queue(ADT)
queue
是通过序列容器来实现的。queue
主要有以下几个特点:
① queue
遵循先入先出(FIFO)原则,queue不提供元素的任何迭代器操作,不会向外部提供可用的前向或后向迭代器类型。
② queue
只能从一端插入,另一端删除,不能遍历,不支持随机存储。
③ priority_queue
包括所有的queue
的性质,在默认的优先队列中,优先级最高的先出队(若是int类型,则为大的先出队)
④ priority_queue
没有迭代器,不提供遍历功能。
③ queue
的常用操作:
使用时注意包含头文件 #include <queue>
q.empty()// 如果队列为空返回true,否则返回false
q.size() // 返回队列中元素的个数
q.pop() //删除队列首元素但不返回其值
q.front() // 返回队首元素的值,但不删除该元素,priority_queue没有此成员
q.push(X) //在队尾压入新元素 ,X为要压入的元素
q.back() //返回队列尾元素的值,但不删除该元素
q.top() // 返回队首元素的值,但不删除该元素,priority_queue独有的成员
priority_queue<int> q; //默认模式
priority_queue<int,vector<int>,less<int>> q; //大顶堆模式,从大到小出队
priority_queue<int,vector<int>,greater<int>> q; //小顶堆模式,从小到到出队
//自定义比较器
struct cmp{
bool operator()(T*a,T*b){
return a->val>b->val; //小顶堆
// return a->val<b->val; //大顶堆
}
};
priority_queue<T*,vector<T*>,cmp> priQueue;
2.2 栈与队列的基本操作
💗 2.2.1 用队列实现栈
💗 2.2.2 用栈实队列
2.2 栈的常见用法
💗 2.2.1 单调栈
所谓单调栈,就是栈中存放的数据是有序的,单调栈分为单调递增栈和单调递减栈。元素加入栈前,会在栈顶端把破坏栈单调性的元素都删除(出栈)。
Q1. 单调栈适用什么问题 ?
通过单调栈,我们可以访问下一个比当前元素大或小的元素。在队列或数组中,我们需要通过比较前后元素的大小关系来解决问题时我们通常使用单调栈。
① 单调递增栈 (栈内元素单调增)
单调递增栈就是一个保持栈内元素为单调递增的栈,当前元素i < 栈顶元素top
时,让栈顶元素出栈,直到当前元素i > 栈顶元素top
,此时下一个小于当前元素i
的值就是栈顶元素top
。因此在单调递增栈遍历过程中,就是在查找每一个下一次小于vec[i]
的值。
若栈当前栈顶为b,新元素为a:
(1).当a > b
时,则将元素a插入栈顶,新的栈顶则为a
(2).当a < b
时,则将从当前栈顶位置向前查找(边查找,栈顶元素边出栈),直到找到第一个比a小的数,停止查找,将元素a插入栈顶(在当前找到的数之后,即此时元素a找到了自己的“位置”)
当遍历完成后,栈中所有剩余的元素都是递增排列,第一个元素是所有数据的最小元素,出栈时元素是单调递减的。
Q2. 单调递增栈有什么作用 ?
单调递增栈主要作用是求数组中元素的下界(下界就是对于当前元素来说,数组中第一个小于该元素值的元素)。下界分为前下界和后下界。
(1). 以O(n)的计算复杂度找到序列中每一个元素vec[i]
的前下界PLE(一个元素的前下界是指对这个元素vec[i]
“左边”第一个小于该元素的值)。如[3,7,8,4,2,5],7的前下界是3,8的前下界是7,4的前下界是3,5的前下界是2,2,3没有前下界。
(2). 以O(n)的计算复杂度找到序列中每一个元素vec[i]
的后下界NLE(一个元素的后下界是指对这个元素vec[i]
“右边”第一个小于该元素的值) 。如[3,7,8,4,2,5],4的后下界是2,8的后下界是4,7的后下界是4,3的后下界是2,2,5没有后下界。
② 单调递减栈 (栈内元素单调递减)
单调递减栈就是一个保持栈内元素为单调递减的栈,
当前元素i > 栈顶元素top
时,让栈顶元素出栈,直到当前元素i < 栈顶元素top
,此时下一个大于当前元素i
的值就是栈顶元素top
。因此在单调递减栈遍历过程中,就是在查找每一个下一次小大于vec[i]
的值。
若栈当前栈顶为b,新元素为a:
(1).当a < b 时,则将元素a插入栈顶,新的栈顶则为a
(2).当a > b 时,则将从当前栈顶位置向前查找(边查找,栈顶元素边出栈),直到找到第一个比a大的数,停止查找,将元素a插入栈顶(在当前找到的数之后,即此时元素a找到了自己的“位置”)
当遍历完以后,栈中所有剩余的元素都是递减排列, 第一个元素是所有数据的最大元素,出栈时元素是单调递增的。
Q3. 单调递减栈有什么作用 ?
单调递减栈主要作用是 求数组中元素的上界(上界就是对于当前元素来说,数组中第一个大于该元素值的元素)。上界分为前上界和后上界。
(1). 以O(n)的计算复杂度找到序列中每一个元素的前上界PGE(一个元素的前下界是指对这个元素“左边”第一个大于该元素的值)。如[3,7,8,4,2,5],4的前上界是8,2的前上界是4,5的前上界是8,3,7,8没有前上界。
(2). 以O(n)的计算复杂度找到序列中每一个元素的后上界NGE(一个元素的后下界是指对这个元素“右边”第一个大于该元素的值) 。如[3,7,8,4,2,5],2的后上界是5,4的后上界是5,7的后上界是8,3的后上界是7,8,5没有后上界。
2.4 栈的基本问题
💗 2.4.1 接雨水问题
2.5 队列
💗 2.5.1 优先队列(ADT)
所谓优先队列ADT,就是元素被赋予优先级。在出队时,出队顺序是按照优先级大小进行出队。优先队列分为最大优先队列和最小优先队列。
在优先队列中,只允许在底端加入新元素,并从顶端取出元素,其内的元素并不是按照入队的顺序进行排列的。
1. 优先队列有什么作用 ?
由于优先队列出队时有顺序,因此当在元素集合中寻找最小或最大元素时,可以使用优先队列来完成。
3. 字符串
3.1 STL中的字符串
使用时注意包含头文件 #include <string>
(1).比较操作 == , > , < , >= , <= , !=
(2).字符串特性
s.size(); //返回当前字符串的大小
s.length(); //返回当前字符串的长度
s.empty(); //当前字符串是否为空
(3).字符串其他常用函数
s.insert(pos,s); //在pos位置插入字符串s
s.replace(p,n,s); //删除从p开始的n个字符,然后在p处插入串s
s.erase(p,n); //删除p开始的n个字符
s.substr(pos,n); //返回从pos开始的n个字符组成的字符串
s.swap(s1); //交换当前字符串与s1字符串
s.append(s); //把字符串s连接到当前字符串结尾
s.push_back(c); //在当前字符串尾部添加一个字符c
s.c_str(); //将string转为const char*
3.2 常见的字符串问题
💗 3.2.1 最长公共子序列(LCS-sequence)
子序列,即一个给定的序列的子序列,就是将给定序列中多个元素去掉之后得到的结果。
设字符串A(n)=" a 0 , a 1 , a 2 . . . a n a_0,a_1,a_2...a_n a0,a1,a2...an",B(m)=" b 0 , b 1 , b 2 . . . b m b_0,b_1,b_2...b_m b0,b1,b2...bm",且A和B的最大公共子序列为S(k)=" s 0 , s 1 , s 2 . . . s k s_0,s_1,s_2...s_k s0,s1,s2...sk",则有如下性质:
① 若 a n = b m a_n=b_m an=bm,则 s k = a n = b m s_k=a_n=b_m sk=an=bm,且S(k-1) = s 0 , s 1 , s 2 . . . s k − 1 =s_0,s_1,s_2...s_{k-1} =s0,s1,s2...sk−1是A(n-1) = a 0 , a 1 , a 2 . . . a n − 1 =a_0,a_1,a_2...a_{n-1} =a0,a1,a2...an−1和B(m-1) = b 0 , b 1 , b 2 . . . b m − 1 =b_0,b_1,b_2...b_{m-1} =b0,b1,b2...bm−1的最长公共子序列。
② 若 a n ! = b m a_n!=b_m an!=bm,则 s k ! = a n s_k!=a_n sk!=an,说明S(k) = s 0 , s 1 , s 2 . . . s k =s_0,s_1,s_2...s_k =s0,s1,s2...sk是A(n-1) = a 0 , a 1 , a 2 . . . a n − 1 =a_0,a_1,a_2...a_{n-1} =a0,a1,a2...an−1和B(m)= b 0 , b 1 , b 2 . . . b m b_0,b_1,b_2...b_m b0,b1,b2...bm的最长公共子序列。
③ 若 a n ! = b m a_n!=b_m an!=bm,则 s k ! = b m s_k!=b_m sk!=bm,说明S(k) = s 0 , s 1 , s 2 . . . s k =s_0,s_1,s_2...s_k =s0,s1,s2...sk是A(n) = a 0 , a 1 , a 2 . . . a n =a_0,a_1,a_2...a_n =a0,a1,a2...an和B(m-1)= b 0 , b 1 , b 2 . . . b m − 1 b_0,b_1,b_2...b_{m-1} b0,b1,b2...bm−1的最长公共子序列。
根据上述性质可以得出:
① 假如A的最后一个元素与B的最后一个元素相等,那么A和B的LCS就等于 【序列A(n-1) 与 B(m-1) 的 LCS 再加上A和B相等的最后一个元素】
② 假如A的最后一个元素与B的最后一个元素不相等,那么A和B的LCS就等于 【序列A(n-1) 与 B(m) 的 LCS】 与 【序列A(n) 与 B(m-1) 的 LCS】中最大的那个序列。
因此,可以得到其递归公式为:
S ( i , j ) = { 0 若 i=0 || j=0 S ( i − 1 , j − 1 ) + 1 若 i,j>0, A i = B y m a x ( S ( i , j − 1 ) , S ( i − 1 , j ) ) 若 i,j>0, A i ≠ B y S(i,j)= \begin{cases} 0& \text{若 i=0 || j=0}\\ S(i-1,j-1)+1& \text{若 i,j>0,$A_i=B_y$} \\ max(S(i,j-1),S(i-1,j))& \text{若 i,j>0,$A_i≠B_y$} \end{cases} S(i,j)=⎩⎪⎨⎪⎧0S(i−1,j−1)+1max(S(i,j−1),S(i−1,j))若 i=0 || j=0若 i,j>0,Ai=By若 i,j>0,Ai=By
int findLCS_sequence(string a,string b){
if(a.size()==0 || b.size()==0)
return 0;
vector<vector<int>> dp(a.size(),vector<int>(b.size()));
// 初始化第0行
for(int i=0;i<b.size();i++){
if(a[0]==b[i]){
for(int j=i;j<b.size();j++)
dp[0][j]=1;
break;
}else
dp[0][i]=0;
}
// 初始化第0列
for(int i=0;i<a.size();i++){
if(a[i]==b[0]){
for(int j=i;j<a.size();j++)
dp[j][0]=1;
break;
}else
dp[i][0]=0;
}
for(int i=1;i<a.size();i++){
for(int j=1;j<b.size();j++){
if(a[i]==b[j])
dp[i][j]=dp[i-1][j-1]+1;
else
dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
}
}
return dp[a.size()-1][b.size()-1];
}
💗 3.2.2 最长公共子串(LCS-substring)
子串,是在给定字符串中任意个连续的字符组成的子序列。
设字符串A(n)=" a 0 , a 1 , a 2 . . . a n a_0,a_1,a_2...a_n a0,a1,a2...an",B(m)=" b 0 , b 1 , b 2 . . . b m b_0,b_1,b_2...b_m b0,b1,b2...bm",且A和B的最大公共子串为S(k)=" s 0 , s 1 , s 2 . . . s k s_0,s_1,s_2...s_k s0,s1,s2...sk",则有如下性质:
① s k = a n = b m s_k=a_n=b_m sk=an=bm,且S(k-1) = s 0 , s 1 , s 2 . . . s k − 1 =s_0,s_1,s_2...s_{k-1} =s0,s1,s2...sk−1是A(n-1) = a 0 , a 1 , a 2 . . . a n − 1 =a_0,a_1,a_2...a_{n-1} =a0,a1,a2...an−1和B(m-1) = b 0 , b 1 , b 2 . . . b m − 1 =b_0,b_1,b_2...b_{m-1} =b0,b1,b2...bm−1的最长公共子串。
② 若 a n ! = b m a_n!=b_m an!=bm,则上面条件不成立,即S(k)不可能是A(n)和B(m)的最大公共子串。
根据上述性质可以得出:
① 假如A的最后一个元素与B的最后一个元素相等,那么A和B的最大公共子串就等于 【序列A(n-1) 与 B(m-1) 的 最大公共子串 再加上A和B相等的最后一个元素】
② 假如A的最后一个元素与B的最后一个元素不相等,那么A和B的最大公共子串就等于 0
因此,可以得到其递归公式为:
S ( i , j ) = { 1 若 i=0 || j=0, A i = B y S ( i − 1 , j − 1 ) + 1 若 i,j>0, A i = B y 0 A i ≠ B y S(i,j)= \begin{cases} 1& \text{若 i=0 || j=0,$A_i=B_y$ }\\ S(i-1,j-1)+1& \text{若 i,j>0,$A_i=B_y$} \\ 0& \text{$A_i≠B_y$} \end{cases} S(i,j)=⎩⎪⎨⎪⎧1S(i−1,j−1)+10若 i=0 || j=0,Ai=By 若 i,j>0,Ai=ByAi=By
int findLCS_substring(string a,string b){
if(a.size()==0 || b.size()==0)
return 0;
int result=0;
vector<vector<int>> dp(a.size(),vector<int>(b.size()));
for(int i=0;i<a.size();i++){
for(int j=0;j<b.size();j++){
if(a[i]==b[j]){
if(i==0 ||j==0) //首行和首列
dp[i][j]=1;
else
dp[i][j]=dp[i-1][j-1]+1;
result=max(result,dp[i][j]); //获取最大的子串长度
}
else
dp[i][j]=0;
}
}
return result;
}
💗 3.2.3 字符串查找(匹配)
字符串查找匹配是字符串的基本操作之一,通常是给定一个长度为N的文本字符串Str
和一个长度为M(M<N)的模式字符串Pat
,在文本字符串Str
中查找和该模式字符串Pat
相同的子字符串。
常见的字符串查找算法包括:KMP算法,Sunday算法,BM算法。
① BM算法
BM算法由坏字符规则和好后缀规则组成。BM算法的文本串Str
和模式串Pat
是从后向前比较的。
BM算法步骤如下:
Step1: 从后向前对文本串Str和模式串Pat一一匹配。
Step2: 当匹配失败时,先前匹配成功的字符构成好后缀Good
.
(1).找出好后缀Good
的所有后缀子串。
(2).找出模式串Pat
的所有前缀子串。
(3).找到好后缀Good
中最长的能和模式串Pat
的前缀子串匹配的后缀子串.
Step3: 将模式串Pat
移动,使Pat
前缀子串与Good
后缀子串匹配
② KMP算法
KMP算法是一种改进的字符串查找匹配算法。KMP算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的,消除了指针i的回溯问题。KMP算法的时间复杂度O(m+n)。设主串为Str
,模式串为Pat
,以
Str=BBC ABCDAB ABCDABCDABDE Pat=ABCDABD 为例,介绍KMP算法。
Step1: 寻找模式串Pat
中各个子串的前缀后缀最长公共元素长度
Next
数组,
Next
数组相当于最大长度表整体向右移动一位,然后初始值赋值为-1。
vector<int> getnext(string needle){
int len=needle.size();
vector<int>next;
next.push_back(-1); //next数组的首位为-1
int slow=-1; //slow指向子串的当前匹配的前缀位置
int quick=0; //quick指向子串的当前匹配的后缀位置 如:ABCBA,slow指向第一个A,quick指向最后一个A
//ABCDAB,slow指向第二个索引B,quick指向最后一个索引B
while(quick<len){
if(slow==-1 || needle[slow]==needle[quick]){
slow++; //slow的值就是最大公共元素长度
quick++;
next.push_back(slow);
}else
slow=next[slow];
}
return next;
}
Step3: 根据Next数组进行字符串匹配:
假设现在文本串S匹配到 i 位置,模式串P匹配到 j 位置。
(1).如果j=-1
,或者当前字符匹配成功(即Str[i] == Pat[j]),都令i++,j++
,继续匹配下一个字符;
(2).如果j !=-1
,且当前字符匹配失败(即S[i] != P[j]),则令 i
不变,j = Next[j]
。此举意味着失配时,模式串P相对于文本串S向右移动了j-Next[j]
位。换言之,当匹配失败时,模式串向右移动的位数为:失配字符所在位置 - 失配字符对应的next 值,即移动的实际位数为:j-next[j]
,且此值大于等于1。 如下图所示:
class Solution {
public:
vector<int> getnext(string needle){ //计算Next数组
vector<int> next;
int slow=-1;
int quick=0;
next.push_back(-1);
while(quick<needle.size()){
if(slow==-1 || needle[slow]==needle[quick]){
slow++;
quick++;
next.push_back(slow);
}else
slow=next[slow];
}
return next;
}
//KMP算法
int strStr(string haystack, string needle) {
if(needle.empty())
return 0;
int len1=haystack.size();
int len2=needle.size();
vector<int>next; //next数组
next=getnext(needle); //获取next数组
int i=0;
int j=0;
while(i<len1 && j<len2){
if(j==-1 || haystack[i]==needle[j]){
i++; //主串Str指针
j++; //模式串Pat指针
}else{
j=next[j]; //当前字符匹配失败,i不变,j=next[j]
}
}
if(j==len2)
return i-j;
return -1;
}
};
4. 链表
链表是一种物理存储结构上非连续,非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现。
Tips 1.链表使用时注意事项
● 当修改当前元素指针时,其原本指向的指针会断开。如果没有保存原本的指针,则会导致当前指针原本指向的指针成为野指针,导致内存泄露。如1->2->3->4->5->null,当修改元素3指向的指针时,若没有保存元素4,则会使从元素4往后的内存泄露。
● 链表通过指针指向下一个元素的地址,实现逻辑上的数据连续,因此,链表通过递归只能从头指针开始遍历到尾指针。 但是可以通过递归的思想来实现链表从尾指针到头指针的 “遍历”。
Q1. 链表与数组的区别 ?
① 数组是静态分配内存,链表是动态分配内存;
② 数组在内存中是连续的,链表是不连续的;
③ 数组的随机访问性强,可通过下标快速定位,查找速度快。链表的查找效率低,但是插入和删除效率高。
4.1 常见的链表问题
💗 4.1.1 环形链表
环形链表是指在链表中包含一个环形的链表。如下图所示:
Tips 2. 环形链表的性质
① 设环的长度为
R
,快指针
fast
的移动是慢指针
slow
的两倍,两者距离为
P
,如图所示。当慢指针
slow
进入入口点后
t
时间内,快指针走了
2t
个节点,慢指针走了
t
个节点。
当慢指针与快指针相遇时有S+2t-t=nR
即s+t=nR
② 当两个指针在cross点相遇时,慢指针走了
S+L
,快指针走了
L+S+nR
,又因为快指针
fast
的移动是慢指针
slow
的两倍,所以
2(L+S)=L+S+nR
,即
L+S=nR
,当n=1时,
L=R-S
,所以
如果采用两个指针,一个从表头出发,一个从相遇点出发,那么它们将同时到达环入口。即
二者相等时便是环入口节点
class Solution {
public:
bool hasCycle(ListNode *head) {
ListNode *s=head; //s为慢指针
ListNode *t=head; //t为快指针
while(t!=NULL && t->next!=NULL){
s=s->next;
t=t->next->next; //快指针每次比慢指针多走一步
if(s==t) //快指针与慢指针相遇,但此时的指针s,t并不是环形链表的交点
return true;
}
return false;
}
};
💗 4.1.2 相交链表
两个链表在某一结点处相交。如下图所示
Tips 3. 相交链表的性质
从图中可以看出,当A与B相交时,A走了
La
路程,B走了
Lb
路程,然后一起走了
L
路程。为了求出其相交结点,当A走到终点后,从B开始走,当B走到终点后,从A开始走。根据路程循环,则有
La+L+Lb=Lb+L+La
,此时两指针在相交点相遇。
class Solution {
public:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
if(headA==nullptr|| headB==nullptr)
return nullptr;
ListNode* hA=headA;
ListNode* hB=headB;
while(hA!=hB){
hA = hA==NULL ? headB : hA->next; //当A走到终点后,从B开始走
hB = hB==NULL ? headA : hB->next; //当B走到终点后,从A开始走
}
return hA;
}
};
💗 4.1.3 反转链表
1. 链表的完全反转
将链表翻转。[ a->b->c->d->e->null ] => [ e->d->c->b->a->null ]
① 迭代法:
② 递归法:
fan
2. 反转链表前 N 个节点
将链表前N个节点翻转。[ a->b->c->d->e->f->null ] => [ d->c->b->a->e->f->null ]
此问题与【链表的完全反转】问题的区别是终止条件的不同。【链表的完全反转】问题的终止条件是head
为空时,返回head
,而【反转链表前 N 个节点】问题,在n=1时,其head!=NULL
,所以需要一个全局变量来保存head->next
指针
ListNode *Next= NULL; //保存n=1时,head!=NULL,因此需要保存head的后驱节点: head->next
ListNode* reverseN(ListNode *head, int n) {
if (n == 1) {
Next= head->next; // 记录第 n + 1 个节点(后驱节点)
return head;
}
//在本级递归中,有三个结点 [head]->[head->next]<-[reverseN()]
ListNode *last = reverseN(head->next, n - 1); // 以 head.next 为起点,需要反转前 n-1个节点
head->next->next = head;
head->next = Next; // 让反转之后的 head 节点和后面的节点连起来
return last;
}
3. 反转链表的一部分
将链表中(m,n)
的部分翻转,[ a->b->c->d->e->f->null ] => [ a->b->e->d->c->f->null ]
在此问题中,当m=1时,问题就变成了【反转链表前 N 个节点】问题。但当m!=1
时,需要对从头结点前进至反转的起点。
class Solution {
public:
ListNode *Next=NULL; //保存n=1时,head!=NULL,因此需要保存head的后驱节点: head->next
//反转前n为的链表
ListNode * reverseN(ListNode *head,int n){
/* [head]->[head->next]<-reverseN() Next
| ^
| |
---------------------------------- */
if(n==1){
Next=head->next;//记录head的后驱节点: head->next
return head;
}
ListNode *ans=reverseN(head->next,n-1);
head->next->next=head;
head->next=Next; //接上head的后驱节点: head->next
return ans;
}
ListNode* reverseBetween(ListNode* head, int m, int n) {
// [head]->reverseBetween()
if(m==1)
return reverseN(head,n);
head->next=reverseBetween(head->next,m-1,n-1);
return head;
}
};
4.3 双向链表
双向链表在单链表的基础上增加了指向前序节点的指针,其结构图如下图所示:
Q1. 单链表与双链表的区别 ?
5. 树
树是由结点或顶点和边组成的且不存在着任何环的一种数据结构。没有结点的树称为空(null或empty)树。一棵非空的树包括一个根结点,还有多个子结点,所有结点构成一个多级分层结构。
当我们面对树结构时,不应该把树结构看成一种特殊的算法,树结构只是一种“树”形式的数据存储方式,可以将树看成一种特殊的数组。树结构与数组之间只是数据遍历方式的不同:对于数组,在遍历过程中只存在两个方向,但对与树结构可分为前序遍历,中序遍历和后序遍历三种方式,对于不同的树,其通过不同的遍历方式存在着不同性质 (如,BST树在中序遍历时就是排序后的数组,当搜索路径始终向下就是前序遍历)。
面对树的数据结构问题,重点是要理解不同种类的树的性质,以及其运算过程。在树的数据结构中,常用递归来求解,必须熟悉掌握递归过程中的逻辑关系。
针对树数据结构的递归问题,通常通过以下一个三个步骤去理解和建立递归:
① 整个递归的终止条件及终止后的返回值。
② 找到递归的返回值-注意,这里的递归返回值与终止后返回值不同,这里是指应该向上一级返回什么信息。
③ 本级递归 (一个结点与其左右结点) 应该做怎样的运算(递归是一个反复调用自身的过程,这就说明它每一级的功能都是一样的,只要确定了其中一级的递归操作就确定了整个递归过程) 不要去纠结每一级调用和返回的细节。
Q1. 二叉树的基本定义与性质
5.1 二叉树基本操作
💗 5.1.1 二叉树遍历
二叉树遍历按深度与广度可以分为深度优先遍历DFS
和广度优先遍历BFS
。按遍历顺序可以分为前序遍历,中序遍历和后序遍历。
● 根据二叉树遍历关系构建二叉树
前序遍历,中序遍历和后序变遍历之间的关系如下图所示。从图中看出,已知前序遍历和中序遍历、中序遍历和后序遍历可以确定唯一的一个二叉树,但如果只知道前序遍历和后序遍历则无法确定二叉树。
● 深度优先遍历 DFS
在这里主要介绍迭代方法的DFS
:
● 广度优先遍历 BFS
BFS通常需要利用辅助队列。将每个结点的左右子树入队,然后每次从队列中出队首元素。
💗 5.1.2 二叉树深度
输的深度就是根节点到叶子结点之间的节点数量。二叉树的深度分为 最小深度(最近叶子结点)和 最大深度(最远叶子结点)
💗 5.1.3 二叉树宽度
树的宽度是所有层中的最大宽度。每一层的宽度被定义为两个端点(该层最左和最右的非空节点,两端点间的null节点也计入长度)之间的长度。
class Solution {
public:
int widthOfBinaryTree(TreeNode* root) {
if(root==NULL)
return 0;
queue<pair<TreeNode *,unsigned long long>> que;
que.push(make_pair(root,1));
int ans=1;
while(!que.empty()){
int len=que.size();
ans=max(ans,int(que.back().second-que.front().second+1));
for(int i=0;i<len;++i){
TreeNode *node=que.front().first;
unsigned long long pos=que.front().second;
que.pop();
if(node->left!=NULL)
que.push(make_pair(node->left,2*pos));
if(node->right!=NULL)
que.push(make_pair(node->right,2*pos+1));
}
}
return ans;
}
};
5.2 二叉查找树-BST
二叉查找树又叫二叉排序树,二叉搜索树(Binary Sort Tree
)。BST可以用于数据的排序存储,其数据查找的时间复杂度与二分查找的时间复杂度相同。 因此,很多的数据库的数据存储方式采用的是二叉搜索树。
💗 5.2.1 BST 性质
💗 5.2.2 BST 基本操作
struct BSTNode* BSTree::removeNode(BSTNode *node,int value){
if(node==nullptr)
return nullptr;
if(value > node->value)
node->_right=removeNode(node->_right,value);
else if(value < node->value)
node->_left=removeNode(node->_left,value);
else{ //node->val==value
if(node->_left!=nullptr && node->_right!=nullptr){
BSTNode *pre=node->_left;
while(pre->_right!=nullptr) //找到结点左子树的最大结点
pre=pre->_right;
node->value=pre->value; //将左子树的最大结点的值与当前节点的值交换
node->_left=removeNode(node->_left,pre->value);
}else{
if(node->_left!=nullptr){ //左子树不空,返回左子树
BSTNode * left=node->_left;
delete node;
return left;
}else if(node->_right !=nullptr){ //右子树不空,返回右子树
BSTNode * right=node->_right;
delete node;
return right;
}else{
delete node;
return nullptr;
}
}
}
return node;
}
💗 5.2.2 BST的缺点
虽然二叉搜索树在数据排序存储方面有很高的效率,但是不同的排序方式会导致二叉搜索树 “失去平衡”,形成只有一个分支的 “链表”。为了防止二叉搜索树失去平衡,提出了平衡二叉树AVL
。
5.3 平衡二叉树AVL
AVL
的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。
💗 5.3.1 平衡因子
某结点的左子树与右子树的高度(深度)差即为该结点的平衡因子(BF,Balance Factor
)。平衡二叉树上所有结点的平衡因子只可能是 -1,0 或 1。如果某一结点的平衡因子绝对值大于1则说明此树不是平衡二叉树。
💗 5.3.2 AVL 插入节点
由于AVL插入节点时会破坏原有的平衡,因此需要对AVL进行调整,保证AVL的平衡。AVL插入节点分为4种情况,如下图所示,其中,左左和右右称为外侧插入,采用单旋转进行调整,左右和右左称为内侧插入,采用双旋转进行调整。
5.4 红黑树 RB Tree
由于AVL插入节点时需要不断的旋转,保证AVL的平衡,因此提出了RB Tree,RB Tree的插入删除比AVL更便于控制,且整体性能优于AVL树,旋转的情况少于AVL树。
① RB Tree是基于BST进行数据存储的,是有序的,而Hash Table是基于Hash进行数据存储的,是无序的。
② Hash查找速度比RB Tree快,且查找速度与数据量的大小无关,为O(1),而RB Tree的查找速度是O(logn)
💗 5.4.1 RB Tree插入节点
RB Tree插入节点根据插入位置以及插入位置的父节点情况分为5种情况:
5.5 赫夫曼树
6. 哈希(散列)表
散列表是在记录的存储位置和它的关键字之间建立一个确定的对应关系。其原理见【算法基本思想】
6.1 STL中的散列集合
在C++ STL中包含多种类型的散列集合(关联容器),如map,undered_map,set,undered_set等等。
(1). set
set在底层使用平衡的搜索树—红黑树实现,只保存关键字key,set主要有以下几个特点:
① set在插入删除操作时不需要内存移动和拷贝。
② set中的元素是唯一的(不能有重复元素),默认对元素自动进行升序排列。
③ 不能直接修改set容器中元素的值。要修改某元素的值,需要先删除该元素,再插入新元素。
④ set的常用操作:
使用时注意包含头文件 #include <set>
s.begin() 返回set容器的第一个元素的迭代器
s.end() 返回set容器的最后一个元素迭代器
s.clear() 删除set容器中的所有的元素
s.empty() 判断set容器是否为空
s.insert() 插入一个元素
s.erase() 删除一个元素
s.size() 返回当前set容器中的元素个数
(2). unordered_set
unordered_set
基于哈希表实现,数据插入和查找时间复杂度低,但空间复杂度较高。unordered_set
的特点如下:
① unordered_set
无自动排序功能
② unordered_set
无法插入相同的元素
③ unordered_set
的常用操作:
使用时注意包含头文件 #include <unordered_set>
us.begin() 返回unordered_set容器的第一个元素的迭代器
us.end() 返回unordered_set容器的最后一个元素迭代器
us.clear() 删除unordered_set容器中的所有的元素
us.empty() 判断unordered_set容器是否为空
us.insert() 插入一个元素
us.erase() 删除一个元素
us.size() 返回当前set容器中的元素个数
us.count() 返回unordered_set中元素的个数,只能是0或1
(3). map
map提供一对一的hash,能够建立key-value一一对应的关系,map内部基于红黑树建立。在map中的元素是基于pair
的,map内部的所有数据都是有序的。
map常用操作:
使用时注意包含头文件 #include <map>
m.begin() 返回map容器的第一个元素的迭代器
m.end() 返回map容器的最后一个元素迭代器
m.clear() 删除map容器中的所有的元素
m.empty() 判断map容器是否为空
m.insert(pair<>) 插入一个pair元素
m.find() 查找一个元素
m.erase() 删除一个元素
m.rbegin() 返回一个指向map尾部的逆向迭代器
m.rend() 返回一个指向map头部的逆向迭代器
m.size() 返回当前map容器中的元素个数
m[] / m.at() 访问元素
(4). unordered_map
unordered_map内部元素是无序的。
unordered_map常用操作:
使用时注意包含头文件 #include <unordered_map>
um.begin() 返回unordered_map容器的第一个元素的迭代器
um.end() 返回unordered_map容器的最后一个元素迭代器
um.clear() 删除unordered_map容器中的所有的元素
um.empty() 判断unordered_map容器是否为空
um.insert(pair<>) 插入一个pair元素
um.find() 查找一个元素
um.erase() 删除一个元素
um.rbegin() 返回一个指向unordered_map尾部的逆向迭代器
um.rend() 返回一个指向unordered_map头部的逆向迭代器
um.size() 返回当前unordered_map容器中的元素个数
um[] / m.at() 访问元素
7. 图论
在图论中,有四个重要的图模型:无向图(简单连接),有向图(连接有方向),加权图(连接带权重值),加权有向图(连接既有权重值又有方向性)
7.1 图的基本概念
图是由顶点的有穷非空集合和顶点之间边的集合组成, 通常表示为: G(V,{E}), 其中,G表示一个图,V是图G中顶点的集合,E是图G中边的集合。在图形结构中,数据元素(顶点)之间具有任意关系,图中任意两个数据元素之间都可能相关。
💗 7.1.1 无向图
无向图是图中的顶点之间的连接没有方向性,如果一个无向图的任意两个顶点之间都存在边,则称无向图为无向完全图。
💗 7.1.2 有向图
有向图是图中的顶点之间的连接具有方向性,如果一个有向图的任意两个顶点之间都存在方向互为相反的两条弧,则称有向图为有向完全图。
7.2 图的存储结构
图的存储结构有两种方式:邻接矩阵和邻接表
💗 7.2.1 邻接矩阵
图的邻接矩阵存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。
💗 7.2.2 邻接表
对于边数相对顶点较少的图,邻接矩阵结构对存储空间产生的极大浪费的,特别是稀疏有向图。所以可以考虑用链表来按需存储。数组与链表相结合的存储方法称为邻接表。
7.3 图的遍历方式
图的遍历是指:从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次图,包含两种遍历方式:DFS
和BFS
;
● DFS:它从图中某个顶点v出发,访问此顶点,然后从v的未被访问的邻接点出发深度优先遍历图,直至图中所有和v 有路径相通的顶点都被访问到。
● BFS:BFS类似于树的层次遍历
7.4 图的基本特性与应用
💗 7.4.1 欧拉通路与回路
如果图G中的一个路径包括每个边恰好一次,则该路径称为欧拉路径,如果路径是一个回路,则称为欧拉回路。具有欧拉回路的图称为欧拉图,具有欧拉路径而无欧拉回路的图称为半欧拉图。
● 无向图存在欧拉回路的充要条件:一个无向图存在欧拉回路,当且仅当该图所有顶点度数都为偶数,且该图是连通图。
● 一个有向图存在欧拉回路,所有顶点的入度等于出度且该图是连通图。
💗 7.4.2 拓扑排序
拓扑排序是将有向无环图的所有顶点排成一个线性序列,使得图中任意一对顶点u和v,若边<u,v>∈E(G),则u在线性序列中出现在v之前。 拓扑排序是有向无环图,因此可以表示事情完成的前后顺序,即必须在某个项⽬完成后才能开始实施另一个子项目,其主要应用场景包括任务的安排调度,课程,会议安排等。
拓扑排序主要包含两个作用:
① 得到一个拓扑序,但拓扑序不唯一
② 检测有向图是否存在环
1. BFS求解拓扑排序
# BFS 拓扑排序(N叉树的BFS遍历)
void BFS(节点个数,条件列表){
for(条件列表){
将条件列表得到邻接表 vector<vector<Type>>adjacent;
统计每个节点的入度,定义入度表 vector<Type>indegree;
}
for(节点个数){
定义一个队列queue,将所有入度为0的节点入队;
}
定义拓扑排序结果数组;
while(队列不为空){
将队首出队,并删除队首;
将当前队首加入排序结果数组;
条件元素数量--;
for(遍历以队首为行的邻接表中的元素){
if(--当前邻接表元素的入度==0){
将当前邻接表元素入队;
}
}
}
if(条件元素数量==0)
则该有向图是有向无环图
else
有向图是有向有环图
}
2. DFS求解拓扑排序
# DFS 拓扑排序
# 注意:由于图的有向连接中,一个节点可能会有多个入度,
# 特别当有向图存在一个环时,一个节点可能被其他节点dfs访问,也可能由本节点dfs访问,
# 因此访问标志数组不能通过简单的 该节点是否访问 来标志,需要分为[由本节点dfs访问],[其他节点dfs访问],[未访问]
void DFSFunc(节点个数,条件列表){
for(条件列表){
将条件列表得到邻接表 vector<vector<Type>>adjacent;
}
访问标志数组visit(节点个数);
for(i:节点个数){
if(!DFS(当前节点i,邻接表,访问标志数组))
return false;
}
return true;
}
bool DFS(当前节点i,邻接表,访问标志数组){
if(visit[当前节点i]==-1) //当前节点被其他dfs访问
return true;
if(visit[当前节点i]==1) //当前节点被本次dfs访问
return false;
visit[当前节点i]=1;
for(遍历以当前节点为行的邻接表中的元素j){
if(!DFS(j,邻接表,访问标志数组))
return false;
}
visit[当前节点i]=-1;
return true;
}
7.5 最短路径问题
计算图的最短路径是常见的问题之一,在这一问题中,存在多种算法:
在计算最短路径时,要坚守一个准则:如果要让任意两点(例如从顶点a点到顶点b)之间的路程变短,只能引入第三个点(顶点k),并通过这个顶点k中转即a->k->b
,才可能缩短原来从顶点a点到顶点b的路程。
💗 7.5.1 Dijkstra算法
Dijkstra
算法利用BFS解决带权有向图或无向图的单源最短路径问题,最终得到最短路径树。由于Dijkstra
算法是通过遍历dis[]中未访问的节点,获取最小路径节点作为第三个点的,因此如果图中的权值为负时,就无法通过最小值来计算第三个点,因此导致算法失效。
# Dijkstra算法框架
邻接表/邻接矩阵 graph;
访问标记数组 visited;
最短路径表 dist[];
for(循环次数为节点数-1){ //一次计算除自身的其他节点(依次将其他节点作为第三节点)
for(i:所有节点){ //找到当前未访问节点中的最短的路径
int temp=-1;
if(!vistitedi] && (temp==-1 || dist[temp]>dist[i])) //当图中存在负权值是,此处失效
temp=i; //当前最短路径的下标
}
visited[i]=true;
for(i:当前最短路径节点所指向的所有节点graph[temp]){ //更加最短路径表
dist[i]=min(dist[i],dist[temp]+graph[temp][i]);
}
}
💗 7.5.2 Bellman-Ford算法
为了解决Dijkstra
算法中存在的负权值问题,提出了Bellman-Ford
算法。Bellman-Ford
算法为了避免负权值问题,采用了先入先出队列的方式:每次从队列中读取首节点,并以该首节点为第三节点,更新dis[],并将修改的节点加入到队列中。不断的从队列中取节点 -> 更新dis[] -> 更新节点放入队尾
,直到队列为空。
同时,为了防止图中存在负权环路,导致队列死循环,还要检测图中是否存在负权环路,判断负权环路的方式有两种:
① 开始算法前,调用拓扑排序进行判断 (效率低)
② 如果某个点进入队列的次数超过N次则存在负环 (N为图的顶点数)
# Bellman-Ford 算法框架
邻接表/邻接矩阵 graph;
访问标记数组visited[];
最短路径表 dist[];
节点入队次数数组 count[];
queue<> que;
将起始节点入队,添加到dist[]中;
while(!que.empty()){
top=取出队首,并出队;
for(i:遍历以队首为第三节点所指向的所有节点graph[top]){
if(dist[i]>dist[top]+graph[top][i]){
更新dist;
if(如果更新的节点不在队列中!visited[i]){
visited[i]=true;
que.push(i);
++count[i];
if(如果节点进入队列的次数超过节点总数){
说明图中存在负权环路,则此时无法计算出最短路径,直接退出;
return;
}
}
}
}
}
8. Hash
散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系,使得每个关键字key
对应一个存储位置f(key)
。其中对应关系f
为散列函数(哈希函数),存储记录的连续空间称为散列表。key---散列函数 --->索引
散列表最适合求解查找与给定值相等的记录。如果是对应同一个关键字对应多个记录,或者范围的查找,不适合散列表查找。
散列表查找通常分为两步:
① 在存储时,通过散列函数计算记录的散列地址,并按此散列地址存储该记录。
② 在查找记录时,通过同样的散列函数计算记录的散列地址,并按此散列地址访问该记录。
因此,散列表中最重要的是设计散列函数。减少散列函数的冲突(如:f(key1)=f(key2)
)
① 直接定址法:取关键字的某个线性函数值作为散列地址,即
f(key)=a*key+b
。
此方法只能使用与关键字较少的情况下。
② 数字分析法:抽取关键字中的一部分,并对数据进行翻转,位移,叠加等方式来提供一个散列函数,计算散列存储位置。 此方法适合处理关键字位数较大的情况。(如:1234改成4321,1234改成12+34=46等)
③ 平方取中法:求取关键字的平方,再抽取平方结果的中间
m
位,作为散列地址。
此方法适合于不知道关键字的分布,而位数不是很大的情况(如:1234->平方为1522756->取中间3位为227,作为散列地址)
④ 折叠法:将关键字从左到右分割为位数相等的几部分(最后一部分可以短一些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。 此方法不需要知道关键字的分布,适合关键字位数较多的情况(如:9876543210->分为四组 987|654|321|0 ->叠加求和 987+654+321+0=1962 -> 求后3位962作为散列地址)
⑤ 除留余数法:此方法的散列函数为:
f(key)=key mod p (p≤m)
,关键是选择合适的
p
。若散列表表长为m,
通常p为小于或等于表长(接近m)的最小质数,或不包含小于20质因子的合数。(说人话就是 选择离散列表长度最近的质数)
⑥ 随机数法
f(key)=random(key)
: random是随机函数,当关键字的长度不等时,采用这个方法构造散列函数是合适的。
Q2. 如何处理散列冲突 ?
无论怎么设置散列函数,都无法避免散列函数的冲突,即
key1≠key2
,却有
f(key1)=f(key2)
。
① 开放定址法:
开放定址法原则就是 一旦发生冲突,就去寻找下一个空的散列地址。开放地址法主要分为三个类型
f1(key)=(f(key)+di) mod m (di=1,2,3...,m-1)
。
解决冲突的开放地址法称为线性探测法,如
(以19 01 23 14 55 68 11 86 37为例,H(key)=key MOD 11
)**有如下处理步骤:
● 二次探测法: 即增加平方运算不让关键字都聚集在某一块区域。其公式为
f{key}={f(key)+di} mod m (di=1^2,-1^2,2^2,-2^2,...,q^2,-q^2 q≤m/2)
。如
(以19 01 23 14 55 68 11 86 37为例,H(key)=key MOD 11
)。 在线性探测的基础上,从依次往后遍历变成按照
±(1²),±(2²),±(3²),±(4²)...
的规律进行探测,在线性探测的基础上。
● 随机探测法:对位移量
di
采用随机函数得到,称之为随机探测法。
② 再散列函数法:事先多准备几个散列函数,当出现散列冲突时,就换一个散列函数进行计算。即
f(key)=RHi(key)
,
RHi
为不同的散列函数。
③ 链地址法(拉链法):当出现散列冲突时,将所有为同义词的关键字的记录存储到单链表中,散列表只记录所有同义词子表的头指针。
④ 公共溢出区法: 当出现散列冲突时,将冲突的关键字存入公共溢出区,在查找时,首先利用散列函数查找关键字,若查找不到,则在公共溢出区中顺序查找。
9. 位运算
程序中的所有数在计算机内存中都是以二进制的形式储存的,即0、1两种状态。位运算就是直接对整数在内存中的二进制位进行操作。
常见的位运算有: