学习数据结构与算法的目的:
优化时间复杂度与空间复杂度
优化时间复杂度与空间复杂度
优化时间复杂度与空间复杂度
教程总纲: 暴力解法(模拟)
、算法优化(递归/二分/排序/DP)
、时刻转换(数据结构)
1.时间复杂度的核心方法论
空间是廉价的,时间是昂贵的
相较于空间复杂度(投入金钱 增加算力),时间复杂度(消耗时间)更为重要!
降低时间与空间复杂度的方法:
时刻转换:选用合适的数据结构,进一步降低时间复杂度
例.输入数组 a = [1,2,3,4,5,5,6] 中查找出现次数最多的数值。
暴力解法是:两层for遍历,维护一个最大次数time_max,对每个元素计算出现次数time_tmp,与time_max进行对比,时间复杂度是 0 ( n 2 ) 0(n^2) 0(n2)
int main(){
vector<int> a={1,2,3,4,5,5,6};
int val_max=-1,time_max=0,time_tmp=0;
for(int i=0;i<a.size();i++){
time_tmp=0;
for(int j=0;j<a.size();j++)
if(a[i]==a[j]) time_tmp++;
if(time_tmp>time_max){
time_max=time_tmp;
val_max=a[i];
}
}
cout<<val_max<<" "<<time_max<<endl;
return 0;
}
优化思想:如何仅用单层for循环完成,用hash思想,引入k-v字典数据结构map,一次for保存每个元素出现的次数,再求每个元素次数的最大值,时间复杂度是 0 ( 2 n ) 0(2n) 0(2n)。
int main(){
vector<int> a={1,2,3,4,5,5,6};
map<int ,int> num_cnt;
int val_max,time_max=0;
for(int i=0;i<a.size();i++){
num_cnt[a[i]]++; //counting the number of times a[i occurs in the vector a.
}
for(auto it:num_cnt){ //iterating over the map and printing the max time a[i] occurs for each element.
if(time_max < it.second){
val_max=it.first; //assigning the maximum value from the map to val_max.
time_max=it.second; //assigning the maximum count from the map to time_max.
}
}
cout<<val_max<<" "<<time_max<<endl;
return 0;
}
2.增删查——选取数据结构的基本方法
当你不知道用什么数据结构的时候:
分析需要对数据进行了哪些操作,根据数据操作,选取合适的数据结构
分析需要对数据进行了哪些操作,根据数据操作,选取合适的数据结构
分析需要对数据进行了哪些操作,根据数据操作,选取合适的数据结构
还用上面的例子介绍:
对于统计次数最多的元素,我们需要对数据结构进行以下操作:
具体的:
所以
3.线性表——如何完成基本增删查
实际上,有线性存储
(数组)和链式存储
(链表)两种结构,这里仅介绍链式存储。
单向链表:
循环链表:
双向链表:
双向循环链表:
线性表增删查:其他链表的操作与单向链表雷同,仅介绍单向链表
增加操作:
删除操作:
查找操作:
总结:
链表的查找速度慢
(
无法用
i
n
d
e
x
)
O
(
n
)
,但插入和删除
(
改变指针
)
方便
O
(
1
)
链表的查找速度慢(无法用index)O(n),但插入和删除(改变指针)方便O(1)
链表的查找速度慢(无法用index)O(n),但插入和删除(改变指针)方便O(1)
链表的问题常常围绕数据顺序的处理:链表反转
,快慢指针
例1.
为此,我们使用3个指针prev、curr、next,分别指向 新链表头节点、旧链表转换节点、旧链表转换节点的下一个,完成旧链表向链表逐个节点的转换。
#include<bits/stdc++.h>
using namespace std;
#define rep(i,s,e) for(int i=s;i<e;i++)
#define per(i,s,e) for(int i=s;i>e;i--)
struct node{
int data=0;
node* next;
};
int main(){
node*head=new node,*n1=new node,*n2=new node,*n3=new node;
head->data=0;
head->next=n1; n1->data=1; n1->next=n2; n2->data=2; n2->next=n3; n3->data=3;n3->next=NULL;
node*tmp=head;
//输出原链表
while(tmp!=NULL){
cout<<tmp->data<<" ";
tmp=tmp->next;
}cout<<endl;
node* curr=head,*prev=head,*next=head->next;
head->next=NULL;
while(next!=NULL){
curr=next; next=next->next;
curr->next=prev; prev=curr;
}
//输出逆序链表
while(curr!=NULL){
cout<<curr->data<<" ";
curr=curr->next;
}
return 0;
}
/*
0 1 2 3
3 2 1 0
*/
例2.
slow走1步,fast走两步。(因为fast一次走两步,所以要防止fast到fast.next.next为空,所以while的判断条件是3个)
fast到达终点时,slow到达中点。
#include<bits/stdc++.h>
using namespace std;
#define rep(i,s,e) for(int i=s;i<e;i++)
#define per(i,s,e) for(int i=s;i>e;i--)
typedef struct node{
int data=0;
node* next;
}*Node;
int main(){
node*head=new node,*n1=new node,*n2=new node,*n3=new node,*n4=new node,*n5=new node,*n6=new node;
head->data=0;
head->next=n1; n1->data=1; n1->next=n2; n2->data=2;
n2->next=n3; n3->data=3;n3->next=n4,n4->data=4;n4->next=n5;
n5->data=5;n5->next=n6;n6->data=6;n6->next=NULL;//1->2->3->4->5->6
//快慢指针,求链表中间值
node *fast=head,*slow=head;
while(fast!=NULL&&fast->next!=NULL&&fast->next->next!=NULL){
fast=fast->next->next;
slow=slow->next;
}
cout<<slow->data<<endl;
return 0;
}
例3.
基本思想是利用两个指针,一个快指针和一个慢指针,分别从链表头部开始遍历,快指针每次走两步,慢指针每次走一步,若快指针追上了慢指针,则说明链表存在环路;否则,当快指针到达链表尾部时,结束遍历,slow永远不可能和fast相等,链表不存在环路。
#include<bits/stdc++.h>
using namespace std;
#define rep(i,s,e) for(int i=s;i<e;i++)
#define per(i,s,e) for(int i=s;i>e;i--)
typedef struct node{
int data=0;
node* next;
}*Node;
bool cicle(node* head){
node *fast=head,*slow=head;
while (fast && fast->next) {
fast = fast->next->next;
slow = slow->next;
if (fast == slow)
return true;
}
return false;
}
int main(){
node*head=new node,*n1=new node,*n2=new node,*n3=new node,*n4=new node,*n5=new node,*n6=new node;
head->data=0;
head->next=n1; n1->data=1; n1->next=n2; n2->data=2;
n2->next=n3; n3->data=3;n3->next=n4,n4->data=4;n4->next=n5;
n5->data=5;n5->next=n6;n6->data=6;n6->next=n2;//1->2->3->4->5->6
//判断链表循环
if(cicle(head)) cout<<"Loop found"; else cout<<"No loop found"; cout<<endl;
}
4.栈——先进后出的增删查
顺序栈:
推荐
:用vector
模拟栈时,仅允许在线性表尾部(栈顶)插入删除数据,push_back()
和pop_back()
。
不推荐:也可以用数组模拟。
链栈: 不需要头指针,进维护一个栈顶top指针。
例1.
#include<bits/stdc++.h>
using namespace std;
#define rep(i,s,e) for(int i=s;i<e;i++)
#define per(i,s,e) for(int i=s;i>e;i--)
int main(){
string str;
cin>>str;
vector<char> stk;
rep(i,0,str.size()){
if(str[i]=='['||str[i]=='('||str[i]=='{') stk.push_back(str[i]);
else if(str[i]==']' && stk.back()=='[') stk.pop_back();
else if(str[i]==')' && stk.back()=='(') stk.pop_back();
else if(str[i]=='}' && stk.back()=='{') stk.pop_back();
else {cout<<" error!";break;}
}
}
例2.
#include<bits/stdc++.h>
using namespace std;
#define rep(i,s,e) for(int i=s;i<e;i++)
#define per(i,s,e) for(int i=s;i>e;i--)
int main(){
vector<int> forword_stk,back_stk;
//back_stk.back()是正在浏览的页面
int n; cin>>n; int tmp;
//保存顺序浏览过的页面page 1-5
rep(i,1,6) back_stk.push_back(i);
//回退到页面n
while(back_stk.back()!=n){
tmp=back_stk.back();
forword_stk.push_back(tmp);
back_stk.pop_back();
}
cout<<"looking page "<<back_stk.back();
}
总结:
5.队列——先进先出的增删查
头指针front,尾指针rear
链队: 头节点仅用来表示队列(data=number),不用了存储数据
头节点的意义:给空链表的front 和rear指针一个指向,防止变成野指针。
顺序队列: 数组模拟,队尾插入时间复杂度为O(1),队头删除,后面的所有元素前移,时间复杂度为O(n),如果仅通过移动fornt指针的方式,会造成假溢出
,空间不足的情况。
实际上,上述两种解决方法都不好,假溢出
最优的解决办法是构造循环队列
循环队列:
例1.
总结:
6.数组——基于索引的查找
增加:
删除:
查找;
总结:
7.字符串——字符串匹配与操作
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-m6Llh8zc-1686663211580)(null)]
插入:
删除:
字符串匹配:
暴力匹配:
例题. 可以暴力也,可以动态规划
暴力法:
#include<bits/stdc++.h>
using namespace std;
#define rep(i,s,e) for(int i=s;i<e;i++)
#define per(i,s,e) for(int i=s;i>e;i--)
int main(){
string a="123456";
string b="13452439";
int max_len=0;
string max_string;
rep(i,0,a.size()){
rep(j,0,b.size()){
//先找到第一个匹配的字符,判断后续字符是否匹配
if(a[i]==b[j]){
for(int m=i,n=j;m<a.size() && n<b.size();m++,n++){ //m and n are indices in a and b, respectively, so we add 1 to
if(a[m]!=b[n]) break; //to stop at the first mismatch, which is at a[m]!=b[n] (which is true if m>n)
if(max_len<m-i){
max_len=m-i;//update the maximum length so far found, which is the length of the substring starting from a[i] to the
max_string=a.substr(i,max_len);//last character of a. Note that i is not incremented. This
}
}
}
}
}
cout<<max_string<<endl;//print the substring found, which is the substring starting from a[i] to the last character of a
}
例题.
#include<bits/stdc++.h>
using namespace std;
#define rep(i,s,e) for(int i=s;i<e;i++)
#define per(i,s,e) for(int i=s;i>e;i--)
int main(){
string a="you are a good man";
vector<string> stk;
string tmp;
rep(i,0,a.size()){
if(a[i]==' '){stk.push_back(tmp);tmp.clear();}
else tmp+=a[i];
} stk.push_back(tmp);
while(!stk.empty()){cout<<stk.back()<<' ';stk.pop_back();}
}
总结:面试笔试常考字符串匹配!暴力->KMP
8.树&二叉树——分支与层次关系
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fJUlteq1-1686663187757)(null)]
非完全二叉树,使用顺序存储会浪费大量的存储空间!
递归实现二叉树的前、中、后序遍历
class node{ public: string val; node* left; node* right;};
void PreOrder(node* NODE){
if(NODE==NULL)return;
cout<<NODE->val<<" ";
PreOrder(NODE->left);
PreOrder(NODE->right);
}
void InOrder(node* NODE){
if(NODE==NULL)return;
InOrder(NODE->left);
cout<<NODE->val<<" ";
InOrder(NODE->right);
}
void PostOrder(node* NODE){
if(NODE==NULL)return;
PostOrder(NODE->left);
PostOrder(NODE->right);
cout<<NODE->val<<" ";
}
二叉查找树(二次排序树): 左小右大,左子树上所有结点的关键字均小于根结点的关键字;右子树上的所有结点的关键字均大于根结点的关键字。中序遍历是有序数列!
利用二次排序树的性质,可以实现二分查找,加快查找速度!
!!
二叉排序树的插入:
二叉排序树删除:
例题.
可以暴力搜索,也可以用字典树,层次遍历到叶子节点的路径。
例题.层次遍历,维护OPEN表(队列),不断扩展队首子节点,加入队列。
#include<bits/stdc++.h>
using namespace std;
#define rep(i,s,e) for(int i=s;i<e;i++)
#define per(i,s,e) for(int i=s;i>e;i--)
#include <iostream>
#include <queue>
using namespace std;
// 定义二叉树结构体
struct TreeNode { int val=-1; TreeNode* left; TreeNode* right; TreeNode(int x): val(x), left(NULL), right(NULL) {}};
// 前序建立二叉树,放回根节点,如输入:1 2 3 -1 -1 -1 4 -1 -1
TreeNode* buildTree() {
int n;
cin >> n;
// 判断输入是否合法,空节点输入-1
if (n == -1) return NULL;
// 创建新节点
TreeNode* root = new TreeNode(n);
// 递归创建左右子树
root->left = buildTree();
root->right = buildTree();
return root;
}
// 层次遍历输出二叉树,1 2 4 3
void levelOrder(TreeNode* root) {
if (root == NULL) return;
// 使用队列进行层次遍历
queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
TreeNode* cur = q.front();
q.pop();//不断取出队首节点进行子节点扩展,扩展出的新节点放入队列(OPEN表)
cout << cur->val << " ";
if (cur->left != NULL) {
q.push(cur->left);
}
if (cur->right != NULL) {
q.push(cur->right);
}
}
}
//前序遍历,1 2 3 4
void preOrder(TreeNode* root) {
if (root == NULL) return;
cout << root->val << " ";
preOrder(root->left);
preOrder(root->right);
}
int main() {
TreeNode* root = buildTree();
levelOrder(root);cout<<endl;
preOrder(root);cout<<endl;
return 0;
}
总结:
9.哈希表——高效查找的利器
哈希冲突: 不同对象的哈希地址相同(键值对的值相同)
哈希函数设计: 下面几个方法都可能出现哈希冲突
冲突解决:
线性探测法(沿占用地址逐个向下遍历寻找未占用的地址存放)
链地址法(将相同哈希地址的记录存放在同一条链表上)
总结:优点(CUDR飞快)、缺点(处理输入顺序敏感的问题时,会破坏序列的构造顺序)
如C++的map
例子.
例题.
#include<bits/stdc++.h>
using namespace std;
#define rep(i,s,e) for(int i=s;i<e;i++)
#define per(i,s,e) for(int i=s;i>e;i--)
#include <iostream>
#include <queue>
using namespace std;
int main() {
map<string,int> database;
string s;
while(1){cin>>s;
if(s=="-1") break;
else {database[s]++;cout<<s<<" occurs "<<database[s]<<" times.\n";}
}
return 0;
}
总结:
10.递归——解决汉诺塔问题
例子.中序遍历
例子.汉诺塔问题
总结:
11.分治——利用分治快速完成数据查找
分治需要使用递归
每轮递归的包括:分解问题、解决问题、合并结果
例子.
可以递归实现,也可以循环实现
#include<bits/stdc++.h>
using namespace std;
#define rep(i,s,e) for(int i=s;i<e;i++)
#define per(i,s,e) for(int i=s;i>e;i--)
void binaryfind(int e,vector<int> arr,int low,int hight){
if(arr[(low+hight)/2] == e){cout<<"find "<<e<<endl;return;}
else if(arr[(low+hight)/2] > e) return binaryfind(e,arr,low,(low+hight)/2-1);//left half sorted
else return binaryfind(e,arr,(low+hight)/2+1,hight);//right half sorted
}
int main() {
vector<int> v={1,2,3,5,7,9,12};
binaryfind(2,v,0,v.size()-1);//1 2 3 5 7 9 12
return 0;
}
例题.
总结:
12.排序——经典排序算法解析
冒泡排序: 相邻元素两两比较,逆序对交换,每轮将1个大的元素交换到最后,经过多轮迭代完成排序。稳定:元素相等时不做交换
#include<bits/stdc++.h>
using namespace std;
#define rep(i,s,e) for(int i=s;i<e;i++)
#define per(i,s,e) for(int i=s;i>e;i--)
void bubble_sort(vector<int> &v){
rep(i,1,v.size()){
rep(j,0,v.size()-i){
if(v[j] > v[j+1])
swap(v[j],v[j+1]);
}
}
}
插入排序: 维护一个排好序的序列,不断为每个未插入的元素,与序列元素比较,找到合适的插入位置。稳定排序。
#include<bits/stdc++.h>
using namespace std;
#define rep(i,s,e) for(int i=s;i<e;i++)
#define per(i,s,e) for(int i=s;i>e;i--)
void insert_sort(vector<int> &v){
int tmp;
rep(i,1,v.size()){
tmp=v[i];//取待排序元素v[i]
int j=i-1;
for(j=i-1;j>-1;j--){//在有序序列中,从后向前与tmp元素比较大小
if(v[j]>tmp) v[j+1]=v[j];//将所有大于tmp的元素后移一位,保持有序序列
else break;//如果tmp元素小于或等于元素j,则结束该次比较
}
v[j+1]=tmp;//将tmp元素放在合适的位置
}
}
归并排序: (分治+合并)将待排序序列从中点位置不断地二分为左右两个子序列,分别递归
调用归并排序函数,直到每个子序列长度为1。再对两个已经有序的相邻子序列进行合并,得到一个新的有序序列。 稳定排序
分治:当数组长度为 1 时,该数组就已经是有序的,不用再分解。
当数组长度大于 1 时,该数组很可能不是有序的。此时将该数组分为两段,再分别检查两个数组是否有序(用第 1 条)。如果有序,则将它们合并为一个有序数组;否则对不有序的数组重复第 2 条,再合并。
合并: 归并排序最核心的部分是合并(merge)过程:将两个有序的数组 a[i] 和 b[j] 合并为一个有序数组 c[k]。
从左往右枚举 a[i] 和 b[j],找出最小的值并放入数组 c[k];重复上述过程直到 a[i] 和 b[j] 有一个为空时,将另一个数组剩下的元素放入 c[k]。
为保证排序的稳定性,前段首元素小于或等于后段首元素时(a[i] <= b[j])而非小于时(a[i] < b[j])就要作为最小值放入 c[k]。
#include<bits/stdc++.h>
using namespace std;
#define rep(i,s,e) for(int i=s;i<e;i++)
#define per(i,s,e) for(int i=s;i>e;i--)
void customDoubleMerge(vector<int>&v,int left,int mid,int right){
vector<int> tmp;//创建一个临时数组来存放合并后的元素
int p1=left,p2=mid+1,k=left;//p1指向左界和右界开始位置的元素序列,k指向合并到的元素
//合并[left,mid]和[mid+1,right]两个子序列到tmp中: 不断取 两个有序序列中最小的元素v[p1]或v[p2] 插入tmp中!!!直至其中一个序列耗尽
while(p1<=mid && p2<=right){
if(v[p1]<=v[p2]) tmp.push_back(v[p1++]);//如果左界元素小于右界元素,将它们放进tmp中
else tmp.push_back(v[p2++]);//如果左界元素大于右界元素,将右界元素
}
// 此时存在一个序列未耗尽,p1=mid 或 p2=right
while(p1<=mid) tmp.push_back(v[p1++]);//将剩下的左界元素放进tmp中
while(p2<=right) tmp.push_back(v[p2++]);//将剩下的右界元素放进tmp
rep(i,0,tmp.size()) v[i+k]=tmp[i];//将tmp中的元素放回v原来的位置(注意偏移量k)
}
// 需要传入序列,及左右index
void customMergeSort(vector<int> &v, int left, int right){
if(left<right){
//二分为子序列
int mid=(left+right)/2;
//分别递归对左右进行排序
customMergeSort(v, left, mid);
customMergeSort(v, mid+1, right);
//合并相邻子序列
customDoubleMerge(v, left, mid, right);
}
}
int main() {
vector<int> v={2,9,2,4,6,8,1};
customMergeSort(v,0,v.size()-1); //v=[2,41,2,4,6,8,1]->[2,4,2,41
for(auto e:v)cout<<e;
return 0;
}
快速排序: 也是不断的将序列不断二分,但分割的过程中,保证做左子序列元素<左子序列首元素<右子序列元素
。具体通过维护两个指针实现,从左右端点不断向中间走,如果左指针小于首元素(左序列第一个元素)
,暂存左指针去看右指针,如果右指针小于首元素,则交换指针元素。重复此过程直到左右指针相遇,从相遇处进行二分。(直接对原数组进行操作,无需像归并排序额外开辟空间,再一样赋值回去
)
但存在交换操作(不稳定排序
)
和归并排序不同,第一步并不是直接分成前后两个序列,而是在分的过程中要保证相对大小关系。具体来说,第一步要是要把数列分成两个部分,然后保证前一个子数列中的数都小于后一个子数列中的数。
之后,维护一前一后两个指针 p 和 q,依次考虑当前的数是否放在了应该放的位置(前还是后)。如果当前的数没放对,比如说如果后面的指针 q 遇到了一个比 m 小的数,那么可以交换 p 和 q 位置上的数,再把 p 向后移一位。当前的数的位置全放对后,再移动指针继续处理,直到两个指针相遇。
其实,快速排序没有指定应如何具体实现第一步,不论是选择 m 的过程还是划分的过程,都有不止一种实现方法。
第三步中的序列已经分别有序且第一个序列中的数都小于第二个数,所以直接拼接起来就好了。
#include<bits/stdc++.h>
using namespace std;
#define rep(i,s,e) for(int i=s;i<e;i++)
#define per(i,s,e) for(int i=s;i>e;i--)
void QuickSort(vector<int>&v, int left, int right){//quickSort函数的参数是排序数组和左右索引位置的集合 或
int i,j,temp,t;//i,j是左右子序列的指针
if(left>=right){
return;
}
i=left; j=right; temp=v[left];
while(i<j){//循环直到i或j达到中点位置
while(temp<=v[j]&&i<j) j--;//找到右子序列大于temp的元素的索引j
while(temp>=v[i]&& i<j) i++;//找到左子序列小于temp的元素的索引i
t=v[j]; v[j]=v[i]; v[i]=t;//将两个子序列中的元素交换到合并后的数组
}
v[left]=v[i]; v[i]=temp;//将中点元素放回数组中的位置 或将i设
QuickSort(v,left,j-1);//递归调用函数以对左子序列进行排序
QuickSort(v,j+1,right);//递归调用函数以对右子序列进行排序
}
int main() {
vector<int> v={2,9,2,4,6,8,1};
QuickSort(v,0,v.size()-1); //v=[2,41,2,4,6,8,1]->[2,4,2,41
for(auto e:v)cout<<e;
return 0;
}
总结:
冒泡排序:适用于数据量较小的情况,时间复杂度为 O(N^2),空间复杂度为 O(1),是一种稳定排序算法。
选择排序:适用于数据量较小的情况,时间复杂度为 O(N^2),空间复杂度为 O(1),是一种不稳定的排序算法。
插入排序:适用于数据量较小且基本有序的情况,时间复杂度为 O(N^2),空间复杂度为 O(1),是一种稳定排序算法。但对于基本有序的数据,插入排序效率最高。
归并排序:适用于数据量较大的情况,时间复杂度为 O(NlogN),空间复杂度为 O(N),是一种稳定排序算法。缺点是需要额外的空间开销。
快速排序:适用于数据量较大的情况,时间复杂度为 O(NlogN),空间复杂度为 O(logN),是一种不稳定的排序算法。缺点是在极端情况下可能会出现退化,效率大幅下降。
希尔排序:适用于数据量较大的情况,时间复杂度为 O(N^1.3 - N^2),空间复杂度为 O(1),是一种不稳定的排序算法。缺点是实现较为复杂。
堆排序:适用于数据量较大的情况,时间复杂度为 O(NlogN),空间复杂度为 O(1),是一种不稳定的排序算法。缺点是实现较为复杂。
计数排序:适用于值域范围较小的整数序列,时间复杂度为 O(N+K),空间复杂度为 O(K),是一种稳定排序算法。缺点是需要额外的空间开销。
桶排序:适用于分布比较均匀的数据,时间复杂度为 O(N),空间复杂度为 O(M),是一种稳定排序算法。缺点是对于分布不均匀的数据,效率不如快排和归并排序。
13.动态规划——最优子结构求解复杂问题
其中3是动态规划dp 区别于 分治法
的特点.
例.动态规划解决最短路径问题
暴力法:遍历每种可能的路径,选择最短的那条。
动态规划法:
目标就是S1-S2-S3-S4-S5-S6-S7的总距离Vk,n(S1,Sn)
反向递归求解 最优子结构,同时注意化简!