数据结构与算法
本文介绍常用的数据结构和算法,并给出每种数据结构和算法对应的经典习题,详细的解答可以参考下一篇习题解。本文的目录如下:
- 基础的数据结构
- 高级数据结构
- 排序算法
- 递归和回溯算法
- 深度优先遍历和广度优先遍历算法
- 动态规划
- 二分搜索和贪婪算法
基础数据结构
数组、字符串
- 数组的优点:简单、根据下表查询O(1)
- 缺点:分配连续空间、查找、添加、删除某个元素时必须遍历
- 例子:242有效的字母异位词
思路:由于字母都是小写且只有26个,因此可以用一个长度为26的数组对每个单词中字母出现的顺序计数,判断是否一致。或者第一个单词累加,第二个减,看这个数组的每个字母的计数是否为0.
链表
优点:灵活分配内存,删除、添加元素O(1)
缺点:不能locate
解题技巧:
快慢指针(有时需要用三个指针):【翻转、倒数k个元素、寻找中间位置元素、判断链表是否有环】
构建虚假的链表头:两个排序链表整合、将链表奇数偶数分离
例题:k个一组翻转链表
##栈###特点:后进先出LIFO
算法基本思想:用一个单链表实现,只关心上一次操作,处理完上一次的操作后能在O(1)的时间内查找到更前一次的操作。(不用数组的原因:数组的长度变化不好控制)
例题:20.有效的括号 739.每日温度队列
特点:先进先出
算法思想:双链表
常用场景:广度优先遍历
###双端队列####特点:双链表、头尾两端可以在O(1)的时间进行查看、添加和删除
常用场景:实现长度动态变化的窗口或者连续区间
例题:239.滑动窗口的最大值
##树###树的共性:结构直观、通过树的问题考察递归算法
常考的树的形状:普通二叉树、平衡二叉树、完全二叉树、【二叉搜索树】、四叉树、多叉树。(特殊的树:红黑树、自平衡二叉搜索树不必花费太多时间)
操作:遍历:前序(搜索、建立二叉树)、【中序】(二叉搜索树)、后序(需要利用左右子树信息)掌握递归、非递归写法。
例题:230.二叉搜索中第k小的元素
#高级数据结构##
- 优先队列:常见考点、面试中可以直接使用
- 图:广泛运用的数据结构,大数据:社交网络、地图
- 前缀树:难题、能够熟练实现及运用
- 线段树和树状数组:应用场合比较明确:在图片中修改像素的颜色、求解任意矩形区间的灰度平均值需要用二位的线段树。
##优先队列###
与普通队列区别:每次取出的元素是队列中优先级最高的,优先级别可以自己定义
常用场景:从杂乱无章的数据中按照一定的顺序(优先级)筛选数据。例如:找出前k大的数。本质:二叉堆结构,利用数组实现完全二叉树
特性:
- 数组里的第一个元素拥有最高优先级,给定一个下标i,那么对于array[i]而言。 父节点下标为
(i-1)/2
; - 左侧子节点
为2*i+1
;右侧子节点下标为2*i+2
;每个元素的优先级必须高于它两侧子节点。
- 数组里的第一个元素拥有最高优先级,给定一个下标i,那么对于array[i]而言。 父节点下标为
操作:向上筛选;向下筛选;
- 向上筛选:新的数据加入到优先队列后对数据进行重新筛选,高度为树的高度。复杂度为O(logk)
- 向下筛选:堆顶元素被取出,将最后一个元素放入堆顶,重新筛选。复杂度为O(logk)
- 优先队列初始化:O(n)
例题:347. 前k个高频元素
##图###
基础知识:
- 图的存储和表达:邻接矩阵、邻接表
算法:
- 图的遍历:深度遍历、广度遍历(重要)
- 二部图的检测:树的检测、环的检测:有向图、无向图
- 联合-查找算法(union-find)
- 拓扑排序
- 最短路径算法
- 连通性相关算法
- 图的着色、旅行商问题等
例题:785.判断二分图
##前缀树(字典树)###前缀树广泛应用于字典查找中
- 字典查找:方法一:暴力搜索O(MN);方法二:前缀树O(M)
经典应用:在搜索框输入时,会列出以搜索词开头的相关搜索词;汉语拼音输入法;
重要性质
- 每个节点至少包含两个属性:children和isEnd;children是数组或者集合,包含每个分支中包含的所有字符串;isEnd表示该节点是否为某个字符串的结尾。
- 前缀树的根节点是空(只关心根节点的children)
- 除了根节点,其他所有节点都有可能是单词的结尾,叶子节点一定都是单词的结尾
操作
- 创建:遍历输入的字符串,对每个字符串的字符进行遍历;从根节点开始,将每个字符加入节点的children字符集中;如果字符集已经包含则跳过;如果当前字符是字符串的最后一个,则isEnd标记为真。
- 搜索:从根节点出发,逐个匹配输入的前缀字符;如果遇到则向下一层继续搜索;如果没有则返回。
例题
212.单词搜索
##线段树###
- 线段树出现的原因:对数组数值频繁更新且的任意区间元素求和的时候需要O(N)的时间,而线段树只需要O(logN)
- 线段树的概念:按照二叉树形式存储数据,每个节点保存的是数组里某一段的总和。
- 例题:315.计算右侧小于当前元素的个数:将下标设为数值区间,节点设置为在这个区间的数字个数;遍历数组并更新数中节点的数值;查找的时候从根节点向下遍历即可。
- 题目特征:区间
##树状数组###
- 树状数组出现原因:对数组频繁更新数组的数值且求前k个元素的总和;线段树O(logN);树状数组也可以在O(logN)时间实现,但是代码实现更简单些。
- 基本特征:
- 用数组表示多叉树的结构;优先队列是用数组表示完全二叉树,二树状数组是多叉树。
- 树状数组的第一个元素是空节点
- 如果节点tree[y]是tree[x]的父节点,那么需要满足y = x - (x &(-x))
- 例题:308.二维区域和检索-可变:求动态变化矩阵任意子矩阵里数的总和。
#排序算法#
基本的排序算法【简单直接、要求没有bug】:
- 冒泡排序
- 插入排序
常考的排序算法【解决大部分关于排序问题的关键】:
- 归并排序
- 快速排序
- 拓扑排序
其他【掌握好思想,开阔思路】:
- 堆排序
- 桶排序
##冒泡排序##
void BubbleSort(int[] nums)
{
bool hasExchange = true;
for (int i=0; i<nums.length-1&& hasExchange;i++)
{
hasExchange = false;
for (int j=0;j<nums.length-1-i;j++)
{
if (nums[j]>nums[j+1])
swap(nums,j,j+1);
hasExchange = true;
}
}
}
- 空间复杂度O(1);时间复杂度O(N^2)
- 算法稳定
##插入排序
void SelectSort(int[] nums)
{
for(int i = 1,i<nums.lenth;i++)
{
current = nums[i];
for(j = i-1;j>=0&&nums[j]>current;j--)
{
nums[j+1] = nums[j];
}
nums[j+1] = current;
}
}
##归并排序##
void MergeSort(int[] A,int low, int high)
{
if (low>=high) return;
int mid = (low+high)/2;
MergeSort(A,low,mid);
MergeSort(A,mid+1,high);
merge(A,low,mid,high);
}
void merge(int []nums,int low, int mid, int high)
{
int[] copy = nums.clone();
int k=low, i=low, j=mid+1;
while(k<=high)
{
if(i>mid){nums[k++]=copy[j++];}
else if(j>high){nums[k++]=copy[i++];}
else if(copy[j]<copy[i]){nums[k++]=copy[j++];}
else {nums[k++]=copy[i++];}
}
}
空间复杂O(n)
##快速排序##
void QuickSort(int[] nums,int low, int high)
{
if (low>=high) return;
int p = partition(nums,low,high);
QuickSort(nums,low,p-1);
QuickSort(nums,p+1,high);
}
void partition(int[] nums,int low, int high)//获得基准值
{
swap(nums,randRange(low,high),high);//随机取一个数作为基准值,放在high
int i,j;
for(int i=low,j=low;j<high;j++)//每个数和基准值比较,如果当前值比基准值小,将其放在指针i所在的位置
{
if(nums[j]<=nums[high])
swap(nums,i++,j);
}//循环完毕之后,i左边的数都比基准值小
swap(nums,i,j);//将基准值放在i的位置,i之后的数都比基准值大
return i;//返回基准值的位置
}
空间O(logN)
例题:215
##拓扑排序##
排序前提:有向图、没有环
void TopSort()//图的广度优先搜索
{
//adj表示图,indegree表示入度
Queue<Integer> q = new LinkedList();
for (int v=0; v<V; v++)//将入度为0的顶点加入队列
{
if (indegree[v]==0) q.add(v);
}
while (!q.isEmpty())
{
int v= q.poll();//从队列中取出顶点
print(v);
for(int u=0;u<adj[v].length;u++)//将和这个顶点相邻的定点减1
{
if(--indegree[u] == 0)//如果相邻的顶点入度为0,则加入队列
{
q.add(u);
}
}
}
}
时间复杂度O(N):统计顶点入度,每个顶点遍历一次
#递归、回溯算法#
递归的基本性质:调用函数本身,把大规模问题不断缩小再进行推导
回溯:利用递归的性质,从问题起点出发不断尝试,返回一步甚至多步再做选择,直到抵达终点
##递归算法(自顶向下)##
使看似复杂的问题变得简洁和易于理解
算法思想:
将问题规模变小(把要实现的递归函数看成已经实现好的)
利用小规模问题中得到的结果(解决这个子问题)
结合当前情况得出最终结果(根据子问题的解得到现在问题的答案)
经典例子:汉诺塔问题void hano(char A, char B, char C, int n)
{
if (n>0){
hano(A,C,B,n-1);
move(A,C);
hano(B,A,C,n-1);
}
}
递归写法结构:
function fn(n)
{
//第一步,判断输入或者状态是否非法
if(input/state is invalid){
return;
}
//第二步,判断递归过程是否应该结束
if(match condition){
return some_value;
}
//第三步,缩小问题规模,递归调用
result1 = fn(n1);
result2 = fn(n2);
...
//第四步,整合结果
return combine(result1,result2);
}
例题:91.解码方法;247.中心对称数II
递归的时间复杂度分析:
- 迭代法:比如汉诺塔问题,T(n)= 1+2T(n-1)+1= 2T(n-1)+O(n)=…=2*2^n
- 公式法
对形如T(n) = aT(n/b) + f(n)的递归方程,算法复杂度如下:- 举例1:
T(n) = 2T(n/2) + n,
a=2,b=2,f(n)=n;
$n^{log_ba}=n^1=n$, 符合第二种情况,时间复杂度为O(nlogn) - 举例2:
T(n) = 2T(n/4) + 1
$n^{log_42}=sqrt(n)$
n>1,sqrt(n)>1, 时间复杂度为O(sqrt(n)) - 举例3:
T(n) = 3T(n/2) + $n^2$
$n^{log_23}=n^{1.48}<n^2$
故,时间复杂度为$O(n^2)$
- 举例1:
回溯算法
回溯算法和暴力算法的区别是,回溯算法一步一步向前试探,对每一步试探的结果进行评估,再决定是否继续。
解决问题的模板:
- 判断当前情况是否非法,如果非法就立即返回
- 看看当前情况是否已经满足条件?如果是,就将当前结果保存并返回
- 在当前情况下遍历所有可能出现的情况,并进行递归
- 递归完毕后,立即进行回溯,回溯的方法就是取消前一步进行的尝试
例题:39.组合总和;52.N皇后II
时间复杂度,以N皇后问题为例:
- 假设backtracking函数的执行时间是T(n)
- 首先每次必须遍历所有列,一共有n列,每一列都要对是否冲突进行检查,最多n列,时间复杂度$O(n^2)$
- 然后,递归尝试每种摆放,每次放好一个,规模-1,执行时间为T(n-1)
- 最后,得到表达式$T(n)= n*T(n-1)+O(n^2)$,用迭代法展开即可得到O(T(n))=n!
其他
- 二叉树的定义和遍历
- 归并排序、快速排序
- 动态规划
- 二分搜索
- 数学基础:等差数列、等比数列
深度优先和广度优先
DFS
DFS解决的是连通性的问题,即给定起始点和终点,判断两点之间是否连通。连通的路径有很多条,找出一条即可。
例题:走迷宫(在矩阵中设置起点和终点,以及墙)
实现:深度优先有两种实现方法:递归和非递归。非递归方法直接用栈来实现。
复杂度分析:邻接表O(V+E);邻接矩阵$O(V^2)$
寻找最短路径:暴力法,找出所有路径,比较长短; 优化:一边寻找目的地,一边记录和起始顶点的距离,当发现从某个方向用的步数更少,则更新到这个点的步数,如果步数更多,则不用继续尝试。
BFS
广度优先搜索一般用来解决最短路径问题,从起始点出发,一层一层遍历,每层结点到起始点步数相同,找到目的地即可结束。
双端BFS是同时从起点和终点开始进行的广度优先搜索,可以大大提高搜索的效率。比如,社交网络中判断两个人通过多少人能够认识对方,从认识的人较少的一方开始遍历。
实现:广度优先遍历用队列进行实现。
复杂度分析:邻接表 O(V+E); 邻接矩阵 O(V^2)
例题:迷宫问题中允许打通3堵墙,应该怎么找到最短路径?
暴力法:首先枚举所有的拆墙方法,假设共有K堵墙,最多允许拆3堵,有3种方式,可以不拆、只拆一堵、两堵和三堵。那么一共有C(0,K)+C(1,K)+C(2,K)+C(3,K),约为K^3,若允许拆w堵墙,则共有k^w种方式。在这么多情况下再分别进行BFS,整体复杂度为O(n^2*k^w)。
优化:允许打通一堵墙的情况下,分身为两个人进行BFS搜索,复杂度为2O(n^2);允许打通两堵墙的情况下,分身为3个人,复杂度为3O(n^2)……如果第一个人又遇到一堵墙,不需要再次分身,而是记录下来让第二个人搜索,将这个点放入队列即可。4个分身在各自的平面立面搜索,利用三维矩阵记录每个层面的点即可。