数据结构和算法作为程序员的基本功,一定得稳扎稳打的学习,我们常见的框架底层就是各类数据结构,例如跳表之于redis、B+树之于mysql、倒排索引之于ES,熟悉了底层数据结构,对框架有了更深层次的理解,在后续程序设计过程中就更能得心应手。掌握常见数据结构和算法的重要性显而易见,本文主要讲解了几种常见的数据结构及基础的排序和查找算法,最后对高频算法笔试面试题做了总结。本文会持续补充,希望对大家日常学习或找工作有所帮忙。本文是数据结构第一讲:排序算法
有3点比较重要 (王争)
推荐的书籍及教程
《大话数据结构 程杰》入门
《算法图解》
《数据结构与算法分析:Java语言描述》(大学课本 伪代码)
《剑指offer》 使用的C++语言来实现的,现在我不怎么使用了
《程序员代码面试指南:IT名企算法与数据结构题目最优解》左程云,现在正在看的书
《编程珠玑》(对大数据量处理的算法)
《编程之美》(超级难)
《算法导论》(很厚很无聊)
《算法第四版》(推荐 本书没有动态规划)
《数据结构与算法 极客时间》 王争google
《算法帝国》
《数学之美》
《算法之美》(闲暇阅读) https://github.com/wangzheng0822/algo
《计算机程序设计艺术》面试必刷的宝典
《图解Java数据结构》韩顺平
《数据结构与算法之美》王争
倘若是在日常开发中,算法的基本逻辑,优缺点、适用场景是更为重要的。
如果是考察技术基础,考核的范围应该是算法的基本逻辑,优缺点、适用场景,因为这些技术点在后续具体应用中选择合适的算法来解决问题的时候很有用;如果是考察思维能力,考核的方式应该是给一个具体的算法应用题,来看看面试者的分析和思考过程,例如一道业务上曾经用到的“如何快速计算你好友的好友和你的共同好友数”。
概念 | 简介 |
---|---|
数据结构 | 数组、链表(单链表/双向链表/循环链表/双向循环/静态链表)、栈(顺序栈/链式栈)、队列(双端队列/阻塞队列在线程池中大量使用/并发队列/并发阻塞队列)、散列表(散列函数/冲突解决(链表法/开放寻址)/二分快速定位元素/动态扩容/位图)、二叉树(平衡二叉树/二叉查找树/mysql底层)、树(b树/B+树/2-3树/2-3-4树)、堆(大顶堆/小顶堆/优先级队列/大数据量求topK)、图(图的存储(邻接矩阵/邻接表)/拓扑排序/最短路径/最小生成树/二分图)、跳表(链表可以快速二分查找元素)、Trie树(用于字符串补全/ES底层搜索的字符串匹配) |
算法 | 递归、排序(O(n2)冒泡/选择/插入/希尔 O(lgn)归并/快排/堆排 O(n)计数/基数/桶)、二分查找(线性表/树结构/散列表)、搜索(深度优先/广度优先/A启发式)、哈希算法、字符串匹配算法(朴素/KMP/Robin-Karp/Boyer-Moore/AC自动机/Trie树/后缀数组)、 复杂度分析(空间复杂度/时间复杂度(最好/最差/平均/均摊))、基本算法思想(贪心算法、分治算法、回溯算法、动态规划) 、其他(数论/计算几何/概率分布/并查集/拓扑网络/矩阵计算/线性规划) |
面试题 | 链表:单链表反转(把指针转向),链表中环的检测(遍历+数组保存遍历过的元素/双指针,前指针走两步,后指针走一步),两个有序的链表合并(双重遍历),删除链表倒数第n个结点(双指针,前指针比后指针先走n步),求链表的中间结点(双指针,前指针走两步,后指针走一步)等、栈:在函数调用中的应用,在表达式求值中的应用,在括号匹配中的应用(网页爬虫中< html>< script>的排除)、排序:如何在O(n)的时间复杂度内查找一个无序数组中的第 K大元素(基数排序) |
由于日常开发使用java居多,因此使用JDK提供的Java版各类数据结构更加符合实际需求。
概念 | Java版接口 | Java版抽象类 | Java版实现类 |
---|---|---|---|
数组 | Iterable | AbstractList | AbstractSequentialList , ArrayList , Vector,CopyOnWriteArrayList ,LinkedList,RoleList,RoleUnresolvedList |
队列 | Iterable | AbstractQueue | ConcurrentLinkedDeque , ConcurrentLinkedQueue ,DelayQueue,LinkedBlockingDeque,LinkedBlockingQueue,LinkedTransferQueue,PriorityBlockingQueue,PriorityQueue,SynchronousQueue |
集合 | Iterable | ConcurrentSkipListSet ,CopyOnWriteArraySet,EnumSet,HashSet,LinkedHashSet,TreeSet | |
栈 | AbstractCollection | stack |
排序算法指标
排序方法 | 时间复杂度(表示的是一个算法执行效率与数据规模增长的变化趋势) | 最好最差情况 | 稳定性 | 最小辅助空间(表示算法的存储空间与数据规模之间的增长关系) |
---|---|---|---|---|
选择排序 | n^2 | - | 不稳定 | 空间O(1) |
public static void selectSort(int[] a) {
int temp,flag = 0;
int n = a.length;
for (int i = 0; i < n; i++) {
temp = a[i]; //第一个数据给temp a[i]为已排序区间的末尾
flag = i;
for (int j = i + 1; j < n; j++) {
if (a[j] < temp) {
temp = a[j]; // 值
flag = j; // 位置
}
}
if (flag != i) {
// 最小数据与第一个数据进行交换
a[flag] = a[i];
a[i] = temp;
}
}
}
public static void insertSort(int[] a) {
if (a != null) {
for (int i = 1; i < a.length; i++) {
// 寻找插入的位置
int temp = a[i], j = i;
if (a[j - 1] > temp) {
while (j >= 1 && a[j - 1] > temp) {
a[j] = a[j - 1];//依次后移
j--;
}
}
a[j] = temp;//插入合适的位置
}
}
}
public class 冒泡排序 {
// 冒泡排序,a表示数组,n表示数组大小
public void bubbleSort(int[] a, int n) {
if (n <= 1)
return;
for (int i = 0; i < n; ++i) {
boolean flag = false;// 提前退出冒泡循环的标志位
for (int j = 0; j < n - i - 1; ++j) {
if (a[j] > a[j + 1]) { // 交换
int tmp = a[j];
a[j] = a[j + 1];
a[j + 1] = tmp;
flag = true; // 表示有数据交换
}
}
if (!flag)
break; // 没有数据交换,提前退出
}
}
}
//Java 代码实现
public class ShellSort implements IArraySort {
@Override
public int[] sort(int[] sourceArray) throws Exception {
// 对 arr 进行拷贝,不改变参数内容
int[] arr = Arrays.copyOf(sourceArray, sourceArray.length);
int gap = 1;
while (gap < arr.length) {
gap = gap * 3 + 1;
}
while (gap > 0) {
for (int i = gap; i < arr.length; i++) {
int tmp = arr[i];
int j = i - gap;
while (j >= 0 && arr[j] > tmp) {
arr[j + gap] = arr[j];
j -= gap;
}
arr[j + gap] = tmp;
}
gap = (int) Math.floor(gap / 3);
}
return arr;
}
}
时间复杂度 nlgn 空间复杂度O(lgn) 不稳定 基于分割交换排序的原则,这种类型的算法占用空间较小,他将待排序列表分成三个主要部分:小于基准的元素,基准元素,大于基准的元素
public static void sort(int array[], int low, int high) {
int index;
if (low >= high) {
return;
}
int i = low;
int j = high;
//基准点
index = array[i];
while (i < j) {
//由小到大排列 好吧,通过代码知道了扫描的顺序,从右开始向左扫描,若是交换了元素,从左往右扫描,然后依次进行
while (i < j && array[j] >= index) {
j--; //从右向左扫描
}
if (i < j) {//说明上述array[j]
array[i++] = array[j];
}
while (i < j && array[i] < index) {
i++; //从左向右扫描
}
if (i < j) {//说明上述array[i]>index,while循环跳出,该值放置在基准右侧
array[j--] = array[i];
}
}
//最后把基准元素放上去
array[i] = index;
sort(array, low, i - 1);
sort(array, i + 1, high);
}
思路:选择数组区间A[0…n-1]的最后一个元素A[n-1]作为pivot,对数组A[0…n-1]原地分区,这样数组就分成了三部分,A[0…p-1]、A[p]、A[p+1…n-1],如果p+1=K,那A[p]就是要求解的元素;如果K > p+1, 说明第K大元素出现在A[p+1…n-1]区间,我们再按照上面的思路递归地在A[p+1…n-1]这个区间内查找
public class 查找无序数组的第K大的数 {
public static int kthSmallest(int[] arr, int k){
// 前置校验
if (arr == null || arr.length < k) {
return -1;
}
// 对数组 A[0…n-1] 进行分区
int partition = partition(arr, 0, arr.length - 1);
//经过一轮分区
while(partition + 1 != k){
if(partition + 1 < k){//说明第K大元素出现在A[p+1…n-1]区间
partition = partition(arr, partition + 1, arr.length - 1);
}else{//说明第K大元素出现在A[1…p-1]区间
partition = partition(arr, 0, partition - 1);
}
}
return arr[partition];//一次成功
}
private static int partition(int[] arr, int p, int r){
int pivot = arr[r];
int i = p;
for(int j = p; j <= r-1; j++){
// 这里要是 <= ,不然会出现死循环,比如查找数组 [1,1,2] 的第二小的元素 这操作真的秀
if(arr[j] < pivot){//放基准元素左侧
swap(arr, i, j);
i++;
}
}
swap(arr, i, r);
return i;
}
private static void swap(int[] arr, int i, int j){
if(i == j){
return;
}
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}//时间复杂度O(n)
补充:倘若是现在开发“查找第K大元素” 这个需求,我会将这批数据放进List集合里面,然后使用Collections.sort()方法按大小排序好,然后get第K个元素。
为什么这个算法的时间复杂度为O(n)?
第一次分区查找,我们需要对大小为n的数组执行分区操作,需要遍历n个元素。第二次分区查找,我们只需要对大小为n/2的数组执行分区操作,需要遍历n/2个元素。
依次类推,分区遍历元素的个数分别为、n/2、n/4、n/8、n/16.……直到区间缩小为1。如果把每次分区遍历的元素个数加起来,就是:n+n/2+n/4+n/8+…+1。这是一个等比数列求和,最后的和等于2n-1。所以,上述解决思路的时间复杂度就为O(n)。
使用分治思想,将复杂问题分解为较小的子问题,直到分解的足够小,可以轻松解决问题为止。(将两个有序表合并成一个有序表) 由大到小排列
堆排图解如下
代码如下所示
//使用分治的思想
public static void MergeSort(int array[], int p, int r) {
if (p < r) {
int q = (p + r)/2;
MergeSort(array, p, q);
MergeSort(array, q + 1, r);
Merge(array, p, q, r);
}
}
//Merge的作用:将已经有序的A[p…q]和A[q+1…r]合并成一个有序的数组,并且放入A[p…r]。
public static void Merge(int array[], int p, int q, int r) {
int i, j, k, n1, n2;
n1 = q - p + 1;
n2 = r - q;
int[] L = new int[n1];
int[] R = new int[n2];
for(i = 0, k = p; i < n1; i++, k++){
L[i] = array[k];
}
for(i = 0, k = q + 1; i < n2; i++, k++){
R[i] = array[k];
}
//相当于合并两条有序的链表 由大到小排列
for(k = p, i = 0, j = 0; i < n1 && j < n2; k++){
if (L[i] > R[j]) {
array[k] = L[i];
i++;
} else {
array[k] = R[j];
j++;
}
}
if(i < n1){
for (j = i; j < n1; j++, k++){
array[k] = L[j];
}
}
if(j < n2){
for (i = j; i < n2; i++, k++){
array[k] = R[i];
}
}
}
O(n) 空间复杂度O(rd) 稳定(基数排序必须依赖于另外的排序方法 实质是多关键字排序)
是通过比较数字将其分配到不同的“桶里”来排序元素的。他是线性排序算法之一。
解决方案:1、最高位优先法MSD 2、最低位优先法LSD
算法步骤
基数排序图解如下
参考代码
public class RadixSort implements IArraySort {
@Override
public int[] sort(int[] sourceArray) throws Exception {
// 对 arr 进行拷贝,不改变参数内容
int[] arr = Arrays.copyOf(sourceArray, sourceArray.length);
int maxDigit = getMaxDigit(arr);
return radixSort(arr, maxDigit);
}
/**
* 获取最高位数
*/
private int getMaxDigit(int[] arr) {
int maxValue = getMaxValue(arr);
return getNumLenght(maxValue);
}
private int getMaxValue(int[] arr) {
int maxValue = arr[0];
for (int value : arr) {
if (maxValue < value) {
maxValue = value;
}
}
return maxValue;
}
protected int getNumLenght(long num) {
if (num == 0) {
return 1;
}
int lenght = 0;
for (long temp = num; temp != 0; temp /= 10) {
lenght++;
}
return lenght;
}
private int[] radixSort(int[] arr, int maxDigit) {
int mod = 10;
int dev = 1;
for (int i = 0; i < maxDigit; i++, dev *= 10, mod *= 10) {
// 考虑负数的情况,这里扩展一倍队列数,其中 [0-9]对应负数,[10-19]对应正数 (bucket + 10)
int[][] counter = new int[mod * 2][0];
for (int j = 0; j < arr.length; j++) {
int bucket = ((arr[j] % mod) / dev) + mod;
counter[bucket] = arrayAppend(counter[bucket], arr[j]);
}
int pos = 0;
for (int[] bucket : counter) {
for (int value : bucket) {
arr[pos++] = value;
}
}
}
return arr;
}
private int[] arrayAppend(int[] arr, int value) {
arr = Arrays.copyOf(arr, arr.length + 1);
arr[arr.length - 1] = value;
return arr;
}
}
O(n) 将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序
适用场景:外部排序中(磁盘中,内存有限,无法将数据全部加载到内存中)
算法步骤
动画演示
如果数据存储在链表中,这三种排序算法还能工作吗?
一般而言,考虑只能改变节点位置,冒泡排序相比于数组实现,比较次数一致,但交换时操作更复杂;
插入排序,比较次数一致,不需要再有后移操作,找到位置后可以直接插入,但排序完毕后可能需要倒置链表;
选择排序比较次数一致,交换操作同样比较麻烦。综上,时间复杂度和空间复杂度并无明显变化,若追求极致性能,冒泡排序的时间复杂度系数会变大,插入排序系数会减小,选择排序无明显变化。
Arrays拥有一组static方法(equals():比较两个array是否相等/fill():将值填入array中/sort():用来对array进行排序/binarySearch():在排好序的array中寻找元素/system.arraycopy():array的复制)
Jdk7中Arrays.sort()和Collections.sort()排序方法使用注意: jdk1.6中的arrays.sort()和 collections.sort()使用的是MergeSort; jdk1.7中内部实现转换成了TimSort方法,
对对象间比较的实现
1、有两个参数,第一个是比较的数据,第二个是比较的规则,如果comparator为空,则使用comparableTimSort的sort实现
2、传入的待排序数组若小于MIN_MERGE(Java实现中为32)则从数组开始处找到一组连接升序或严格降序(找到后翻转)的数
BinarySort:使用二分查找的方法将后续的数插入之前的已排序数组
3、开始真正的TimSort过程(选取minRun大小,之后待排序数组将被分成以minRun大小为区块的一块块子数组)
Timsort的思想:找到已经排好序的数据子序列,然后对剩余部分排序,最后合并起来
java提供的默认排序算法
1、对于基础数据类型,目前使用的是所谓双轴快速排序(Dual-Pivot QuickSort),是一种改进的快速排序算法,早期版本是相对传统的快速排序;
2、而对于对象数据类型,目前则是使用TimSort,思想上也是一种归并(Merge)和二分插入排序(binary Sort)结合的优化排序算法。
思路是查找数据集中已经排好序的分区(这里叫run 连续升或降的序列),然后合并这些分区来达到排序的目的。
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = (low + high) / 2;//或者int mid = low+((high-low)>>1);
if (a[mid] == value) {
return mid;
} else if (a[mid] < value) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return -1;
}
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] > value) {
high = mid - 1;
} else if (a[mid] < value) {
low = mid + 1;
} else {
if ((mid == 0) || (a[mid - 1] != value))
return mid; //mid不是第一个数或mid左边的数不是
else high = mid - 1;
}
}
return -1;
}
第二种:查找最后一个值等于给定值的元素
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] > value) {
high = mid - 1;
} else if (a[mid] < value) {
low = mid + 1;
} else {
if ((mid == n - 1) || (a[mid + 1] != value))
return mid;
else
low = mid + 1;
}
}
return -1;
}
第三种:查找第一个大于等于给定值的元素
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] >= value) {
if ((mid == 0) || (a[mid - 1] < value))
return mid;
else
high = mid - 1;
} else {
low = mid + 1;
}
}
return -1;
}
第四种:查找最后一个小于等于给定值的元素
public int bsearch7(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] > value) {
high = mid - 1;
} else {
if ((mid == n - 1) || (a[mid + 1] > value))
return mid;
else
low = mid + 1;
}
}
return -1;
}
public int search(int[] nums, int target) {
if (nums.length == 1 && nums[0] == target)
return 0;
int left = 0;
int right = nums.length - 1;
int mid = 0;
while (left < right) {
mid = (left + right) >> 1;
if (nums[left] == target)
return left;
if (nums[right] == target)
return right;
if (nums[mid] == target)
return mid;
if (nums[mid] > nums[left]) { // 第一种情况
if (target > nums[mid]) {
left = mid + 1; //在mid到左侧最大值区间
} else {//target小于中间值
if (target > nums[left]) {
right = mid - 1;
} else {
left = mid + 1; //在右侧区间
}
}
} else { // 第二种情况 mid小于最左值
if (target > nums[mid]) {//两种情况:1、在mid右侧 2、在左侧
if (target < nums[right]) {
left = mid + 1; //1、在mid右侧
} else {
right = mid - 1; //2、在左侧
}
} else { //在右侧的左边区域
right = mid - 1;
}
}
}
return -1;
}
public int mySqrt(int x) {
return (int)Math.sqrt(x);
}
int mySqrt(int x) {
//注:在中间过程计算平方的时候可能出现溢出,所以用long long。
long i=0;
long j=x/2+1;//对于一个非负数n,它的平方根不会大于(n/2+1)
while(i<=j)
{
long mid=(i+j)/2;
long res=mid*mid;
if(res==x) return mid;
else if(res<x) i=mid+1;
else j=mid-1;
}
return j;
}
方法3:牛顿迭代法 求c的算术平方根就是求f(x)=x^2-c的正根 迭代公式:xn+1=1/2(xn+c/xn)
int mySqrt(int x) {
if (x == 0) return 0;
double last=0;
double res=1;
while(res!=last)
{
last=res;
res=(res+x/res)/2;
}
return int(res);
}
常见的时间复杂度?(表示的是一个算法执行效率与数据规模增长的变化趋势)
时间复杂度 | 概念 |
---|
空间复杂度:(表示算法的存储空间与数据规模之间的增长关系)
常见的空间复杂度就是O(1)、O(n)、O(n2)
// 方法1:使用list (**最常使用**)
public static boolean useList(String[] arr, String targetValue) {
return Arrays.asList(arr).contains(targetValue);
}
// 方法2:使用Set 低效
>public static boolean useSet(String[] arr, String targetValue) {
Set<String> set = new HashSet<String>(Arrays.asList(arr));
return set.contains(targetValue);
}
// 方法3:使用一个简单循环 最高效
>public static boolean useLoop(String[] arr, String targetValue) {
for(String s: arr){
if(s.equals(targetValue))
return true;
}
return false;
}
1、我们要给电商交易系统中的“订单”排序。订单有两个属性(下单时间,订单金额) 需求是按金额从小到大对订单数据排序。对金额相等的订单,按下单时间从早到晚排序
稳定性概念:如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变
思路:先按下单时间给订单排序,排完序之后,使用稳定排序算法,按订单金额重新排序(稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序不变)
2、O(n)时间复杂度内求无序数组中的第K大元素?(利用分区的思想) 代码放在eclipse中
我们选择数组区间A[0…n-1]的最后一个元素A[n-1]作为pivot,对数组A[0…n-1]原地分区,这样数组就分成了三部分,A[0…p-1]、A[p]、A[p+1…n-1]。
如果p+1=K,那A[p]就是要求解的元素;如果K>p+1, 说明第K大元素出现在A[p+1…n-1]区间,我们再按照上面的思路递归地在A[p+1…n-1]这个区间内查找。同理,如果K
3、现在你有10个接口访问日志文件,每个日志文件大小约300MB,每个文件里的日志都是按照时间戳从小到大排序的。你希望将这10个较小的日志文件,合并为1个日志文件,合并之后的日志仍然按照时间戳从小到大排列。如果处理上述排序任务的机器内存只有1GB,你有什么好的解决思路
answer:先构建十条io流,分别指向十个文件,每条io流读取对应文件的第一条数据,然后比较时间戳,选择出时间戳最小的那条数据,将其写入一个新的文件,然后指向该时间戳的io流读取下一行数据,然后继续刚才的操作,比较选出最小的时间戳数据,写入新文件,io流读取下一行数据,以此类推,完成文件的合并, 这种处理方式,日志文件有n个数据就要比较n次,每次比较选出一条数据来写入,时间复杂度是O(n),空间复杂度是O(1),几乎不占用内存。