如有侵权,请告知删除
算法与数据结构(C++)
第一章 数据结构与STL
1.1 数组、字符串、向量 /Array & String & Vector
1.1.1 数组
-
数组的优缺点:
- 优点
构建一个数组非常简单
能让我们在0(1)的时间里根据数组的下标(index) 查询某个元素 - 缺点
构建时必须分配一段连续的空间
查询某个元素是否存在时需要遍历整个数组,耗费O(n)的时间(其中, n是元素的个数)
删除和添加某个元素时,同样需要耗费O(n) 的时间
- 优点
-
C++数组的特点
-
不允许拷贝和赋值
-
使用数组是通常将其转化成指针如果我们传给函数的是一个数组,则实参自动地转换成指向数组首元素的指针(而非整个数组的指针),数组的大小对函数的调用没有影响。
#include<iostream> using namespace std; int GetSize(int data[]) { return sizeof(data); } int main() { int data1[] = {1,2,3,4,5}; int size1 = sizeof(data1);//20 int *data2 = data1; int size2 = sizeof(data2);//4 int size3 = GetSize(data1);//4 cout<<size1<<" "<<size2<<" "<<size3<<endl; return 0; }
-
-
例题:
-
反转字符串
-
1.1.2 STL——vector
-
vector 容器是 STL中最常用的容器之一,它和 array 容器非常类似,都可以看做是对 C++普通数组的“升级版”。不同之处在于,array 实现的是静态数组(容量固定的数组),而 vector 实现的是一个动态数组,即可以进行元素的插入和删除,在此过程中,vector 会动态调整所占用的内存空间,整个过程无需人工干预。
-
vector 常被称为向量容器,因为该容器擅长在尾部插入或删除元素,在常量时间内就可以完成,时间复杂度为
O(1)
;而对于在容器头部或者中部插入或删除元素,则花费时间要长一些(移动元素需要耗费时间),时间复杂度为线性阶O(n)
。 -
vector 容器以类模板 vector( T 表示存储元素的类型)的形式定义在 头文件中,并位于 std 命名空间中。因此,在创建该容器之前,代码中需包含如下内容:
#include <vector> using namespace std;
-
创建vector容器的方法:
//创建空的 vector 容器,因为容器中没有元素,所以没有为其分配空间 std::vector<double> values; values.reserve(20);//通过调用 reserve() 成员函数来增加容器的容量: /*注意,如果 vector 的容量在执行此语句之前,已经大于或等于 20 个元素,那么这条语句什么也不做;另外,调用 reserve() 不会影响已存储的元素,也不会生成任何元素,即 values 容器内此时仍然没有任何元素。 还需注意的是,如果调用 reserve() 来增加容器容量,之前创建好的任何迭代器(例如开始迭代器和结束迭代器)都可能会失效,这是因为,为了增加容器的容量,vector<T> 容器的元素可能已经被复制或移到了新的内存地址。所以后续再使用这些迭代器时,最好重新生成一下。*/ //创建的同时指定初始值以及元素个数 std::vector<int> primes {2, 3, 5, 7, 11, 13, 17, 19}; std::vector<double> values(20);//values 容器开始时就有 20 个元素,它们的默认初始值都为 0。 //如果不想用 0 作为默认值,也可以指定一个其它值 std::vector<double> values(20, 1.0);//第二个参数指定了所有元素的初始值,因此这 20 个元素的值都是 1.0。 //通过存储元素类型相同的其它 vector 容器,也可以创建新的 vector 容器 std::vector<char>value1(5, 'c'); std::vector<char>value2(value1); //用一对指针或者迭代器来指定初始值的范围 int array[]={1,2,3}; std::vector<int>values(array, array+2);//values 将保存{1,2} std::vector<int>value1{1,2,3,4,5}; std::vector<int>value2(std::begin(value1),std::begin(value1)+3);//value2保存{1,2,3}
-
成员函数
v.push_back(1);//c.push_back(elem)在尾部插入一个elem数据。 v.pop_back();//c.pop_back()删除末尾的数据。 //c.assign(beg,end)将[beg,end)一个左闭右开区间的数据赋值给c。 vector<int> v1,v2; v1.push_back(10); v1.push_back(20); v2.push_back(30); v2.assign(v1.begin(),v1.end()); //c.assign (n,elem)将n个elem的拷贝赋值给c。 v.assign(5,10);//往v里放5个10 //c.at(int index)传回索引为index的数据,如果index越界,抛出out_of_range异常。 cout << v.at(2) << endl;//打印vector中下标是2的数据 c.begin()//返回指向第一个数据的迭代器。 c.end()//返回指向最后一个数据之后的迭代器。 //c.rbegin()返回逆向队列的第一个数据,即c容器的最后一个数据。 //c.rend()返回逆向队列的最后一个数据的下一个位置,即c容器的第一个数据再往前的一个位置。 //c.capacity()返回容器中数据个数,翻倍增长。 vector<int> v; v.push_back(1); cout << v.capacity() << endl; // 1 v.push_back(2); cout << v.capacity() << endl; // 2 v.push_back(3); cout << v.capacity() << endl; // 4 //c.clear()移除容器中的所有数据。 //c.empty()判断容器是否为空。 //c.erase(pos)删除pos位置的数据,传回下一个数据的位置。 //c.front()返回第一个数据。 //c.back()传回最后一个数据,不检查这个数据是否存在。 //c.insert(pos,elem) 在pos位置插入一个elem的拷贝,返回插入的值的迭代器。 //c.insert(pos,n,elem)在pos位置插入n个elem的数据,无返回值。 //c.insert(pos,beg,end)在pos位置插入在[beg,end)区间的数据,无返回值。 //c.size()返回容器中实际数据的个数。 //c.resize(num)重新指定队列的长度。(往往用来增加vector的长度,小->大 ok 大->小 没用!) //c.reserve()保留适当的容量。 c.max_size()//返回容器能容量的最大数量。 c1.swap(c2)//将c1和c2交换。 swap(c1,c2)//同上。
-
c.resize(num)和c.reserve()
- reserve是容器预留空间,但并不真正创建元素对象,在创建对象之前,不能引用容器内的元素,因此当加入新的元素时,需要用push_back()/insert()函数。
- resize是改变容器的大小,并且创建对象,因此,调用这个函数之后,就可以引用容器内的对象了,因此当加入新的元素时,用operator[]操作符,或者用迭代器来引用元素对象。
- 再者,两个函数的形式是有区别的,reserve函数之后一个参数,即需要预留的容器的空间;resize函数可以有两个参数,第一个参数是容器新的大小,第二个参数是要加入容器中的新元素,如果这个参数被省略,那么就调用元素对象的默认构造函数。
- reserve只是保证vector的空间大小(capacity)最少达到它的参数所指定的大小n。在区间[0, n)范围内,如果下标是index,vector[index]这种访问有可能是合法的,也有可能是非法的,视具体情况而定。
- resize和reserve接口的共同点是它们都保证了vector的空间大小(capacity)最少达到它的参数所指定的大小。
-
获取第一个元素,获取最后一个元素
/**获取第一个元素*/ cout<<v.front(); cout<<v[0]; cout<<*v.begin(); /**获取最后一个元素*/ cout<<v.back(); cout<<v[v.size()-1];//size是获取大小 cout<<*--v.end();
-
排序
第三个参数为比较器,不写默认为less()
vector<int> v{5,1,2,5,4,0,-1}; sort(v.begin(),v.end(),less<int>());//从小到大 sort(v.begin(),v.end(),greater<int>());//从大到小排序 for(auto x:v) cout<<x;
-
循环
vector<int> v{5,1,2,5,4,0,-1}; for(int i=0;i<v.size();i++) cout<<v[i];//for循环 cout<<endl; for(vector<int>::iterator it=v.begin();it!=v.end();it++) cout<<*it;//迭代器循环 cout<<endl; for(auto it=v.begin();it!=v.end();it++) cout<<*it;//迭代器简化循环 cout<<endl; for(auto x:v) cout<<x;//c++11新特性
1.1.3 STL——string
概念:相当于char*的封装,理解为字符串
- 简单使用
```cpp
/**C中定义字符串以及打印*/
char *ch="asdkajbf";
for(int i=0;ch[i]!='\0';i++) cout<<*(ch+i);
/**C++中*/
string s="ssadaffw";
cout<<s<<endl;
- 获取一行字符串——getline函数
我想获取一行字符串
hello world
C中:
scanf("%s",ch);//1.仅获取一个单词,空格结束 2.ch[100]得设置初始大小
C++中:
string s;
getline(cin,s);//获取一行数据
cout<<s;
4.3.+=运算符
+=对于字符串,字符有效,数字会转为asc码
string s;
s+="hello";
s+=" world";
s+='5';
s+=10;//10对应的asc码是换行
int a=5;//想把a加入字符串
s+=(a+'0');
cout<<s;
4.4.排序——sort函数
string s="5418340";
sort(s.begin(),s.end());//s.begins()是5的地址,s.end()是0后面的地址
cout<<s;
4.5.删除——erase函数
//传入指针
/**begin是头迭代器,end是尾迭代器*/
string s="5418340";
s.erase(s.begin());//删除第一个
s.erase(--s.end());//删除最后一个
cout<<s;
4.6.取子字符串——substr函数
/*
*原型:string substr ( size_t pos = 0, size_t len = npos ) const;
*功能:获得子字符串。
*参数说明:pos为起始位置(默认为0),len为字符串长度(默认为npos)
*返回值:子字符串
*/
/**begin是头迭代器,end是尾迭代器*/
string s="5418340";
s=s.substr(1,3);//取418,取索引为1,往后截断3个
s=s.substr(5,100);//索引为5,截断到最后
s=s.substr(1,-1);//索引为1,截断到最后
cout<<s;
4.7.循环(3种)——s.length()
1.for循环
string s="5418340";
for(int i=0;i<s.length();i++) cout<<s[i];
2.迭代器
for(string::iterator it=s.begin();it!=s.end();it++) cout<<*it;
3.迭代器化简
for(auto it=s.begin();it!=s.end();it++) cout<<*it;
4.利用C++ 11新特性for循环
for(auto x:s) cout<<x;
1.1.4 STL——sort
-
sort 在 STL 库中是排序函数,有时冒泡、选择等 O(n2) 算法会超时,我们可以使用 STL 中的快速排序函数 O(n log n) 完成排序
-
sort 在 algorithm 库里面,原型如下:
template <class RandomAccessIterator> void sort ( RandomAccessIterator first, RandomAccessIterator last ); template <class RandomAccessIterator, class Compare> void sort ( RandomAccessIterator first, RandomAccessIterator last, Compare comp );
-
Sort函数有三个参数:
- 第一个是要排序的数组的起始地址。
- 第二个是结束的地址(最后一位要排序的地址的下一地址)
- 第三个参数是排序的方法,可以是从大到小也可是从小到大,还可以不写第三个参数,此时默认的排序方法是从小到大排序。
-
第一种方法:Sort函数使用模板:
Sort(start,end,排序方法)
-
第二种方法:
- 上述方法实现了从小到大排序,从大到小排序,但是有点麻烦,可以利用现有的头文件进行实现(类型支持“<”,”>”等比较符)。
- functional提供了一种基于模板的比较函数对象,
equal_to、not_equal_to、greater、greater_equal、less、less_equal
- 常用的有
greater ()
从大到小排序,less ()
从小到大排序。
-
第三种方法:编写运算符重载函数
<返回类型说明符> operator <运算符符号>(<参数表>) { <函数体> }123456
对于这种方法,一般应用于自定义数据类型,因为sort排序函数是应用 < 实现功能的。
程序举例:struct Test { int mem1; int mem2; bool operator< (const Test t) { return this->mem1<t.mem1; } };
当然也可以用第一种方法,自定义比较函数实现!!!
-
第四种方法:定义外部比较类:
struct Less { bool operator()(const Student& s1, const Student& s2) { return s1.name < s2.name; //从小到大排序 } }; std::sort(sutVector.begin(), stuVector.end(), Less());
-
sort函数实现原理:
STL中的sort并非只是普通的快速排序,**除了对普通的快速排序进行优化,它还结合了插入排序和堆排序。根据不同的数量级别以及不同情况,能自动选用合适的排序方法。**当数据量较大时采用快速排序,分段递归。一旦分段后的数据量小于某个阀值,为避免递归调用带来过大的额外负荷,便会改用插入排序。而如果递归层次过深,有出现最坏情况的倾向,还会改用堆排序。
1.1.5 pair的用法(map转成vector进行排序)
bool cmp(pair<int,int> a,pair<int,int> b){
return a.first>b.first;
}
int main(){
unordered_map<int,int> m;//无序的,哈希结构(底层)
m[6]=3;
m[5]=8;
m[4]=9;
//m是不可排序的,转换成vector然后进行排序
vector<pair<int,int>> v(m.begin(),m.end());
sort(v.begin(),v.end(),cmp);
for(auto tmp:v){
cout<<tmp.first<<tmp.second<<endl;
}
return 0;
}
1.1.6 set(unordered_set)
概念:集合
- 应用计数、去重
set<int> s;//树状结构,有序
unordered_set<int> s2;//哈希结构,无序,快
s.insert(3);
s.insert(4);
s.insert(4);
s.insert(4);
cout<<s.size()<<endl;
for(auto tmp:s)
cout<<tmp<<" ";
cout<<endl;
for(auto it=s.begin();it!=s.end();it++)
cout<<*it<<" ";
cout<<endl;
1.2 链表/Linked-List
-
线性表:
线性表是N个数据元素的有限序列
- 顺序表(数组)
- 链表
- 静态链表
- 单链表
- 循环链表
- 双向链表
-
单链表:链表中的每个元素实际上是一个单独的对象,而所有对象都通过每个元素中的引用字段链
接在一起。 -
双链表:与单链表不同的是,双链表的每个结点中都含有两个引用字段。
-
链表的优缺点:
- 优点
灵活地分配内存空间
能在O(1)时间内删除或者添加元素 - 缺点
查询元素需要O(n)时间
- 优点
-
解题技巧
- 利用快慢指针(有时候需要用到三个指针:prev curr next)
- 链表的翻转
- 寻找链表中倒数第k个元素
- 寻找链表的中间元素
- 判断链表是否有环
- 构建一个虚假的链表头
- 两个排序链表,进行整合排序
- 将链表的奇偶数按原定顺序分离,生成前半部分为奇数,后半部分为偶数的链表
- 如何训练该技巧
- 在纸上或者白板上画出节点之间的相互关系画出修改的方法
- 利用快慢指针(有时候需要用到三个指针:prev curr next)
1.2.1 STL——list
概念:双向链表
list<int> li;
li.push_back(6);
li.push_front(5);
li.emplace_front(9);
li.emplace_back(10);
li.insert(++li.begin(),2);
for(auto tmp:li) cout<<tmp<<endl;//9 2 5 6 10
for(auto it=li.begin();it!=li.end();it++) cout<<*it<<endl;
1.3 栈/Stack
- 特点
后进先出(LIFO) - 算法基本思想
可以用一个单链表来实现,只关心上一次的操作,处理完上一次的操作后,能在0(1)时间内查找到更前一次的操作 - 例题:
- 实现:数据结构探险——栈篇
1.3.1 STL——stack
-
C++ Stack(堆栈) 是一个容器类的改编,为程序员提供了堆栈的全部功能,——也就是说实现了一个先进后出(FILO)的数据结构。
-
初始化
//c++ stl栈stack的头文件为: #include <stack> stack<int> ;
-
成员函数
empty()//堆栈为空则返回真 pop()//移除栈顶元素 push()//在栈顶增加元素 size()//返回栈中元素数目 top()//返回栈顶元素
-
进制转换(十进制转二进制)
int itob(int decimal){ stack<int> s;int res=0; while(decimal!=0){ s.push(decimal%2); decimal/=2; } while(!s.empty()){ res=res*10+s.top(); s.pop(); } return res; }
-
逆序单词(拓展sstream,stoi,itoa)
输入一行字符串,将字符串逆序打印
输入:hello world my name is steve yu
输出:yu steve is name my world hello
#include <iostream> #include <stack> #include <sstream>//字符流 using namespace std; int main(){ string str; stack<string> s; getline(cin,str); stringstream ss; ss<<str; while(ss>>str){ //每次从ss向str流出一个字符串,但ss的内容保持不变 s.push(str); cout << str << " "; cout << ss.str() << endl; } while(!s.empty()){ cout<<s.top(); s.pop(); if(s.size()!=0) cout<<" "; } return 0; }
-
字符串转数字
//方法一: string s="1234"; int i; stringstream ss; ss<<s; ss>>i; cout<<i; //方法二: string s="1234"; int i=stoi(s); cout<<i;
-
数字转字符串
//方法一: int a=1234; string out; stringstream ss; ss<<a; ss>>out; cout<<out<<endl; //方法二:(c++ 11) int a=1234; cout<<to_string(a)<<endl;
1.4 队列/Queue
- 特点:先进先出(FIFO)
- 常用的场景
广度优先搜索 - 类型
- 普通队列
- 环形队列
队列是一个环
- 实现:数据结构探险——队列篇
1.4.1 STL——queue
-
只能访问queue容器适配器的第一个和最后一个元素。只能在容器的末尾添加新元素,只能从头部移除元素。
-
初始化
//需要头文件<queue> queue<int>que;
-
成员函数
//C++队列Queue类成员函数如下: back()//返回最后一个元素 empty()//如果队列空则返回真 front()//返回第一个元素 pop()//删除第一个元素 push()//在末尾加入一个元素 size()//返回队列中元素的个数
-
queue 的基本操作举例如下:
q.push(x)//queue入队,将x 接到队列的末端。 q.pop()//queue出队,弹出队列的第一个元素,注意,并不会返回被弹出元素的值。 q.front()//访问queue队首元素,即最早被压入队列的元素。 q.back()//访问queue队尾元素,即最后被压入队列的元素。 q.empty()//判断queue队列空,当队列空时,返回true。 q.size()//访问队列中的元素个数
-
queue队列中没有clear()操作,因此清空队列有几种方法:
//第一种:直接用空的队列对象赋值 queue<int>q1 q1=queue<int>(); //第二种:遍历出队列 while(!q.empty())q.pop(); //第三种:使用swap,这种是最高效的,定义clear,保持STL容器的标准 void clear(queue<int>& q) { queue<int>empty; swap(empty,q); }
1.4.2 STL——deque(双端队列)
-
基本实现
可以利用一个双链表
队列的头尾两端能在0(1)的时间内进行数据的查看、添加和删除 -
常用的场景
实现一个长度动态变化的窗口或者连续区间 -
例题:
-
容器deque和vector非常相似,属于序列式容器。都是采用动态数组来管理元素,提供随机存取,并且有着和vector一样的接口。不同的是deque具有首尾两端进行快速插入、删除的能力。
-
创建deque
deque<Elem> c; 创建一个空的deque deque<Elem> c1(c2); 复制deque,复制跟c1一模一样的队列c2 deque<Elem> c(n); 创建一个deque,元素个数为n,且值均为0 deque<Elem> c(n,num); 创建一个deque,元素个数为n,且值均为num c.assign(n,num); 初始化deque, 初始化后元素个数为n,且值均为num deque<int>::iterator it; 正向迭代器 deque<int>::reverse_iterator rit; 逆向迭代器
-
数据访问
c.at(idx); 返回索引下标idx所指的数据(从0开始) c.front(); 返回第一个数据 c.back(); 返回最后一个数据 c.begin(); 返回指向第一个数据的迭代器 c.end(); 返回指向最后一个数据的下一个位置的迭代器 c.rbegin(); 返回逆向队列的第一个数据 c.rend(); 返回指向逆向队列的最后一个数据的下一个位置的迭代器
-
加入数据(pos、beg、end均为迭代器)
c.push_back(num); 在尾部加入一个数据num c.push_front(num); 在头部插入一个数据num c.insert(pos,num); 在该pos位置的数前面插入一个num c.insert(pos,n,num); 在该pos位置的数前面插入n个num c.insert(pos,beg,end); 在该pos位置的数前插入在[beg,end)区间的数据
-
删除数据(pos、beg、end均为迭代器)
c.pop_back(); 删除最后一个数据 c.pop_front(); 删除头部数据 c.erase(pos); 删除pos位置的数据 c.erase(beg,end); 删除[beg,end)区间的数据
-
其他操作
c.clear(); 销毁所有数据,释放内存 c.empty(); 判断容器是否为空 c.resize(n); deque队列的长度置为n,只保留队列前n个数 c.size(); 返回容器中实际数据的个数 swap(c1,c2); 将c1和c2元素互换 //在deque中查找某元素 deque<int>::iterator pos=find(c.begin(),c.end(),num); if(pos!=c.end()) printf("find success\n"); else printf("find failed\n"); //反向遍历队列中的元素: deque<int>::reverse_iterator rit; for(rit = c.rbegin();rit != c.rend(); rit++) printf("%d ",*rit); sort(d.begin(),d.end(),greater<int>());//排序
1.4.3 优先队列 / Priority Queue
-
与普通队列的区别
保证每次取出的元素是队列中优先级最高的
优先级别可自定义 -
最常用的场景
从杂乱无章的数据中按照-定的顺序(或者优先级)筛选数据 -
本质
二叉堆的结构,堆在英文里叫Binary Heap
利用一个数组结构来实现完全二叉树 -
特性
数组里的第-个元素array[0]拥有最高的优先级
给定-个下标i,那么对于元素array[i]而言- 父节点对应的元素下标是(i-1)/2
- 左侧子节点对应的元素下标是2*i + 1
- 右侧子节点对应的元素下标是2*i + 2
数组中每个元素的优先级都必须要高于它两侧子节点
-
其基本操作为以下两个
- 向上筛选(sift up / bubble up)
- 向下筛选(sift down /bubble down)
-
时间复杂度:
- 添加新的数据,取出堆顶元素:O(log k)
- 优先队列的初始化:O(n)
-
例题:
1.6 树/Tree
1.6.1 各种树
-
树的共性
结构直观 -
通过树问题来考察递归算法掌握的熟练程度
-
面试中常考的树的形状有
-
普通二叉树
-
二叉排序树(二叉搜索树)(BST)
- 如果左子树不为空,则左子树上所有结点的值均小于根结点的值。
- 如果右子树不为空,则右子树上所有结点的值均大于根结点的值。
- 左、右子树也分别为二叉排序树。
- 树中没有值相同的结点。
-
平衡二叉树(ASL)
- 是二叉排序树
满足每个结点的平衡因子绝对值不大于1 - 它是一 棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。这个方案很好的解决了二叉查找树退化成链表的问题,把插入,查找,删除的时间复杂度最好情况和最坏情况都维持在O(logN)。但是频繁旋转会使插入和删除牺牲掉O(logN)左右的时间,不过相对二叉查找树来说,时间上稳定了很多。
- 是二叉排序树
-
满二叉树
- 一棵深度为k且有2k - 1 个结点的二叉树称为满二叉树
- 满二叉树每一层的结点个数都达到了最大值
-
完全二叉树
- 若设二叉树的深度为k,除第 k 层外,其它各层 (1~k-1) 的结点数都达到最大个数,第k 层所有的结点都连续集中在最左边,这就是完全二叉树。
-
B树
- 我们都知道二叉查找树的查找的时间复杂度是O(log N),其查找效率已经足够高了,那为什么还有B树和B+树的出现呢?难道它两的时间复杂度比二叉查找树还小吗?
答案当然不是,B树和B+树的出现是因为另外一个问题,那就是磁盘IO;众所周知,IO操作的效率很低,那么,当在大量数据存储中,查询时我们不能一下子将所有数据加载到内存中,只能逐一加载磁盘页,每个磁盘页对应树的节点。造成大量磁盘IO操作(最坏情况下为树的高度)。平衡二叉树由于树深度过大而造成磁盘IO读写过于频繁,进而导致效率低下。
所以,我们为了减少磁盘IO的次数,就你必须降低树的深度,将“瘦高”的树变得“矮胖”。一个基本的想法就是:
(1)、每个节点存储多个元素
(2)、摒弃二叉树结构,采用多叉树 - B树和平衡二叉树稍有不同的是B树属于多叉树又名平衡多路查找树(查找路径不只两个),数据库索引技术里大量使用者B树和B+树的数据结构,让我们来看看他有什么特点;
- B 树可以看作是对2-3查找树的一种扩展,即他允许每个节点有M-1个子节点。
- 根节点至少有两个子节点
- 每个节点有M-1个key,并且以升序排列
- 位于M-1和M key的子节点的值位于M-1 和M key对应的Value之间
- 其它节点至少有M/2个子节点
- 我们都知道二叉查找树的查找的时间复杂度是O(log N),其查找效率已经足够高了,那为什么还有B树和B+树的出现呢?难道它两的时间复杂度比二叉查找树还小吗?
-
B+树
-
一个m阶的B+树具有如下几个特征:
1.有k个子树的中间节点包含有k个元素(B树中是k-1个元素),每个元素不保存数据,只用来索引,所有数据
都保存在叶子节点。2.所有的叶子结点中包含了全部元素的信息,及指向含这些元素记录的指针,且叶子结点本身依关键字的大小
自小而大顺序链接。3.所有的中间节点元素都同时存在于子节点,在子节点元素中是最大(或最小)元素。
1234567
-
-
四叉树
-
多叉树
-
特殊的树:红黑树、自平衡二叉搜索树
-
-
遍历
- 前序遍历(Preorder Traversal)
- 中序遍历(Iorder Traversal):二叉搜索树
- 后序遍历(Postorder Traversal)
-
例题:
- 250.统计同值子树
- 230. 二叉搜索树中第K小的元素
1.6.2 前缀树 / Tree
-
也称字典树
这种数据结构被广泛地运用在字典查找当中 -
什么是字典查找?
例如:给定一系列构成字典的字符串,要求在字典当中找出所有以"ABC"开头的字符串- 方法一:暴力搜索法
时间复杂度: O(M*N) - 方法二:前缀树
时间复杂度: O(M)
- 方法一:暴力搜索法
-
经典应用
- 搜索框输入搜索文字,会罗列以搜索词开头的相关搜索
- 汉语拼音输入法
-
重要性质
- 每个节点至少包含两个基本属性
children:数组或者集合,罗列出每个分支当中包含的所有字符
isEnd:布尔值,表示该节点是否为某字符串的结尾 - 根节点是空的
除了根节点,其他所有节点都可能是单词的结尾,叶子节点一定 都是单词的结尾
- 每个节点至少包含两个基本属性
-
最基本的操作
-
创建
遍历一遍输入的字符串,对每个字符串的字符进行遍历
从前缀树的根节点开始,将每个字符加入到节点的children字符集当中
如果字符集已经包含了这个字符,跳过
如果当前字符是字符串的最后一个,把当前节点的isEnd标记为真 -
搜索
从前缀树的根节点出发,逐个匹配输入的前缀字符
如果遇到了,继续往下一层搜索
如果没遇到,立即返回
-
-
例题:
1.6.3 线段树 / Segment Tree
-
先从一个例题出发
假设我们有一个数组array(0 …n-1].里面有n个元素,现在我们要经常对这个数组做两件事:- 更新数组元素的数值
- 求数组任意- -段区间里元素的总和(或者平均值)
- 方法一:遍历一遍数组
时间复杂度: O(n) - 方法二:线段树
时间复杂度: 0(logn)
-
什么是线段树
一种按照二叉树的形式存储数据的结构,每个节点保存的都是数组里某一段的总和 -
例题:
1.6.4 红黑树
算法导论中红黑树定义:
1.每个节点或者是红色的,或者是黑色的
2.根节点是黑色的
3.每一个叶子节点(最后的空节点)是黑色的
4.如果一个节点是红色的,那么他的孩子节点都是黑色的
5.从任意一个节点到叶子节点,经过的黑色节点是一样的
算法4中红黑树定义:
红黑树与2-3树的等价性
理解了2-3树和红黑树之间的关系
2-3 树:
满足二分搜索树的基本性质
节点可以存放一个元素或者两个元素
1.6.5 树状数组 / Fenwick Tree /Binary Indexed Tree
-
也被称为Binary Indexed Tree
-
先从一个例题出发
假设我们有一个数组array(0 … n-1],里面有n个元素,现在我们要经常对这个数组做两件事:- 更新数组元素的数值
- 求数组前k个元素的总和(或者平均值)
- 方法一:线段树
时间复杂度: O(logn) - 方法二:树状数组
时间复杂度: O(logn)
-
重要的基本特征
- 利用数组来表示多叉树的结构,和优先队列有些类似
优先队列是用数组来表示完全二叉树,而树状数组是多叉树 - 树状数组的第一个元素是空节点
如果节点tree[y]是tree[x]的父节点,那么需要满足y=x-(x& (-x))
- 利用数组来表示多叉树的结构,和优先队列有些类似
-
例题:
力扣308.二维区域和检索-可变
求一个动态变化的二维矩阵里,任意子矩阵里的数的总和
1.7 图 / Graph
-
最基本知识点如下
- 阶、度
- 树、森林、环
- 有向图、无向图、完全有向图、完全无向图
- 连通图、连通分量
-
图的存储和表达方式:邻接矩阵、邻接链表
-
围绕图的算法也是各式各样
- 图的遍历:深度优先、广度优先
- 环的检测:有向图、无向图
- 拓扑排序
- 最短路径算法: Dijkstra、 Bellman-Ford. Floyd Warshall
- 连通性相关算法: Kosaraju. Tarjan. 求解孤岛的数量、判断是否为树
- 图的着色、旅行商问题等
-
必需熟练掌握的知识点
- 图的存储和表达方式:邻接矩阵、邻接链表
- 图的遍历:深度优先、广度优先
- 二部图的检测(Bipartite) 、树的检测、环的检测:有向图、无向图
- 拓扑排序
- 联合-查找算法(Union-Find)
- 最短路径: Dikstra、 Bellman-Ford
-
例题:
1.8 散列表(哈希表)
散列表:根据给定的关键字来计算出关键字在表中的地址的数据结构。也就是说,散列表建立了关键字和存储地址之间的一种直接映射关系。
散列函数:一个把查找表中的关键字映射成该关键字对应的地址的函数,记为Hash(key)=Addr。
冲突:散列函数可能会把两个或两个以上的不同关键字映射到同一地址,称这种情况为“冲突" ,这些发生碰撞的不同关键字称为同义词。
构造散列函数的tips :
1)散列函数的定义域必须包含全部需要存储的关键字,而值域的范围则依赖于散列表的大小或地址范围。
2)散列函数计算出来的地址应该能等概率、均匀地分布在整个地址空间,从而减少冲突的发生。
3)散列函数应尽量简单,能够在较短的时间内就计算出任一关键字对应的散列地址。
Hash函数的构造方法:
-
直接定址法:
直接取关键字的某个线性函数值为散列地址,散列函数为H(key)=axkey+b. 式中,a和b是
常数。这种方法计算最简单,并且不会产生冲突 -
除留余数法:
假定散列表表长为m,取-一个不大于m但最接近或等于m的质数p,利用以下公式把关键字
转换成散列地址。散列函数为H(key)=key% p
除留余数法的关键是选好p,使得每一一个关键宇通过该函数转换后等概率地映射到散列空间上的任-地址,
从而尽可能减少冲突的可能性 -
数字分析法
设关键字是r进制数(如十进制数),而r个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀些,每种数码出现的机会均等;而在某些位上分布不均匀,只有某几种数码经常出现,则应选取数码分布较为均匀的若干位作为散列地址。这种方法适合于已知的关键字集合 -
平方取中法
顾名思义,取关键字的平方值的中间几位作为散列地址。具体取多少位要看实际情况而定。这种方法得到的散列地址与关键字的每一位都有关系, 使得散列地址分布比较均匀。 -
折叠法
将关键字分割成位数相同的几部分(最后一部分的位数可以短一一些),然后取这几部分的叠加和作为散列地址,这种方法称为折叠法。关键字位数很多,而且关键字中每一位上数字分布大致均匀时,可以采用折叠法得到散列地址。
Hash函数的冲突处理办法:
-
开放定址法:
将产生冲突的Hash地址作为自变量,通过某种冲突解决函数得到一个新的空闲的Hash地址。- 1)线性探测法:冲突发生时,顺序查看表中下一个单元(当探测到表尾地址m-1时,下一个
探测地址是表首地址0),直到找出一-个空闲单元(当表未填满时一定能找到一个空闲单元)
或查遍全表。线性探测法会造成大量元素在相邻的散列地址上“聚集"(或堆积)起来,大大降低了查找效率。 - 2)平方探测法:设发生冲突的地址为d,平方探测法得到的新的地址序列为d+12, d-12, d+22…平方探测法是一种较好的处理冲突的方法,可以避免出现"堆积" 问题,它的缺点是不能探测到散列表上的所有单元,但至少能探测到一半单元。
- 3)再散列法:又称为双散列法。需要使用两个散列函数,当通过第一个散列函数H(Key)得到的地址发生冲突时,则利用第:二个散列函数Hash2(Key)计算该关键字的地址增量。
- 4)伪随机序列法:当发生地址冲突时,地址增量为伪随机数序列,称为伪随机序列法。
- 1)线性探测法:冲突发生时,顺序查看表中下一个单元(当探测到表尾地址m-1时,下一个
-
拉链法:
对于不同的关键字可能会通过散列函数映射到同一地址,为了避免非同义词发生冲突,可以把所有的同义词存储在一个线性链表中,这个线性链表由其散列地址唯一标识。 拉链法适用于经常进行插入和删除的情况。
散列表的查找过程:
类似于构造散列表,给定一个关键字Key。
先根据散列函数计算出其散列地址。然后检查散列地址位置有没有关键字。
1)如果没有,表明该关键字不存在,返回查找失败。
2)如果有,则检查该记录是否等于关键字。
①如果等于关键字,返回查找成功。
②如果不等于,则按照给定的冲突处理办法来计算下一个散列地址,再用该地址去执行上述过程。
散列表的查找性能:
和装填因子有关。
装填因子:散列表的装填因子一般记为a,定义为一个表的装满程度。
计算方法为a=表中记录数n / 散列表长度m
散列表的平均查找长度依赖于散列表的填装因子a,而不直接依赖于n或m。
a越大,表示装填的记录越“满”,发生冲突的可能性就越大,反之发生冲突的可能性越小
1.8.1 STL——map(unordered_map pair)
概念:映射(map为树状表,unorderedmap为哈希表)
- map
map<int,int> m;//有序的,树状结构(底层)
m[6]=3;
m[5]=8;
m[4]=9;
for(auto it=m.begin();it!=m.end();it++)
cout<<it->first<<" "<<it->second<<endl;
for(auto tmp:m){
cout<<tmp.first<<" "<<tmp.second<<endl;
}
/*结果:
4 9
5 8
6 3
*/
- unordered_map
mp.insert(Map::value_type(1,"Raoul"));//插入数据
unordered_map<int,int> m;//无序的,哈希结构(底层)
m[6]=3;
m[5]=8;
m[4]=9;
for(auto it=m.begin();it!=m.end();it++)
cout<<it->first<<" "<<it->second<<endl;
for(auto tmp:m){
cout<<tmp.first<<" "<<tmp.second<<endl;
}