1. 复杂度分析
1. 复杂度分析的意义
1. 事后统计法
将代码运行一遍,通过监控和统计手段获取算法执行的时间及内存占用,称为时候统计法.
2. 事后统计方的局限性
- 测试结果受测试环境的影响很大
- 测试结果受测试数据的影响很大
3. 我们需要不依赖具体测试环境及测试数据,就可以粗略估计算法执行效率的方法.
2. 大O复杂度表示法
大O时间复杂度并不表示具体代码真正的执行时间,而是表示代码执行时间随数据规模增大的变化趋势
3. 时间复杂度分析方法
1. 加法法则
代码总的复杂度等于量级最大的那段代码的复杂度
2. 乘法法则
嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
4. 几种常见的时间复杂度量级
1. O(1)
2. O(logn) , O(nlogn)
3. O(m+n) , O(mn
5. 几种常见的空间复杂度量级
O(1) , O(n), O(n^2) , O(logn) , O(nlogn)
6. 最好时间复杂度,最坏时间复杂度,平均时间复杂度,均摊时间复杂度
1. 最好时间复杂度
在最好的情况下,执行一段代码的时间复杂度
2. 最坏时间复杂度
在最坏的情况下,执行一段代码的时间复杂度
3. 平均时间复杂度
最好时间复杂度和最坏时间复杂度,是极端情况下的时间复杂度,发生的概率并不大.
平均时间复杂度指的是代码重复执行无数次,对应的时间复杂度的平均值.
将每种情况发生的概率也考虑进去.
4. 均摊时间复杂度
均摊事件复杂度是平均时间复杂度的一种特殊情况.
对一个数据进行一组连续操作,大部分情况时间复杂度很低,个别情况时间复杂度较高,而且这些操作之间存在规律的时序关系.
这时我们可以将这一组操作一起分析,将较高时间复杂度的那次操作平摊到其他较低时间复杂度的操作上.
2. 数组
1. 线性表
数据排列成像一条线一样的结构.
线性表中的数据只有前后两个方向.
数组,链表,栈,队列都是线性表结构.
2. 非线性表
数据之间不是简单的前后关系.
树,图都是非线性表.
3. 数组定义
数组是一种线性表结构,它用一组连续的内存空间,存储一组具有想同类型的数据.
1. 连续的内存空间
数组中的元素在内存地址上是连续的
2. 相同类型的数据
4. 数组最重要的特性: 随机访问
随机访问是指,数组支持在O(1)时间复杂度内按照下标访问对应的数组元素.
- a[i]_address = base_address + i * data_type_size
- base_address数组内存空间首地址, i是数组元素下标, data_type_size是数组中每个元素占用的内存大小.
5. 数组元素的插入及删除
1. 通常情况下,为保证'内存连续性',数组插入及删除数据,时间复杂度是O(n)
2. 为了提高插入效率,优化措施:
- 首先将要插入的位置的元素放到末尾
- 然后将插入的元素放到指定位置
3. 为了提高删除效率,优化措施:
- 每次删除数据并不真正删除,仅仅标记该位置数据失效
- 当数组中空间不够用时,再统一将标记过的位置的数据删除,统一执行数据搬移
6. 容器能否完全替代数组
1. ArrayList的优势
- 将很多对数组的操作进行了封装,使用便捷
- 支持动态扩容
- 动态扩容涉及数据搬移及内存申请,耗时,若提取能确定数据规模,容器初始化时就要指定容器的容量.
2. ArrayList的劣势
- 无法存储基本类型,使用封装类会涉及 自动装箱拆箱,有性能损耗.
- 多维数组直接使用数组更直观
3. 结论
- 业务开发直接使用容器类即可
- 底层开发,如编写网路框架,做性能优化要优先使用数组.
7. 数组下标为何从0开始
为了计算数组元素内存地址性能最好,下标从0开始,相比从1开始,获取元素内存地址计算少.
- address = base + singleSize * i
- address = base + singleSize * (i - 1)
8. 编程语言中的数组,并不一定完全符合数据结构中数组的定义.
1. C/C++中实现的数组完全符合数组的标准定义,在一块连续的内存空间存储相同类型的数据
2. Java中
- 基本类型数组符合数据结构中数组的定义,在连续的内存空间中存储相同类型数据.
- 对象数组存储的是对象在内存中的地址,而不是实例本身.实例本身在内存中并不连续.
3. 链表
1. 缓存淘汰算法
缓存大小有限,当缓存被填满,哪些数据应该被移除,哪些数据应该被保留,需要缓存淘汰算法来决定.
1. 先进先出 FIFO / First In, First Out
2. 最少使用 LFU / Least Frequently Used
3. 最近最少使用 LRU / Lease Recently Used
2. 链表不需要连续的内存空间,通过指针将一组零散的内存块/结点串联起来.
3. 数组擅长按照下标随机访问, 链表擅长插入及删除操作.
4. 链表种类
1. 单链表
- 每个Node只有next指针
- tail.next = null
2. 双向链表
- 每个Node有next指针及pre指针
- tail.next = null
3. 循环链表
- 每个Node只有next指针
- tail.next = head
4. 双向循环链表
- 每个Node有next指针及pre指针
- tail.next = head
5. 链表和数组对比
- 数组使用一组连续的内存空间,可以有效利用CPU缓存,预读数组中多个元素,提高访问效率; 链表中元素在内存中不连续,对CPU缓存不友好.
- 数组一经声明就要占据连续的内存空间,容器触发OOM. 若声明的数组过小,就会频繁的扩容消耗性能; 链表本身没有内存限制,天然支持动态扩容.
- 链表每个节点需要存储next指针,更消耗内存.如果频繁的插入删除,会涉及频繁的内存申请及释放,触发GC影响性能.如果内存紧张,更适合数组.
6. 利用'哨兵'来简化链表操作代码
public class LinkedList{
public class Node{
public int data;
public Node next;
}
private Node head;
private Node tail;
public LinkedList(){
//哨兵,保证链表永远不为空
Node guard = new Node();
head = guard;
tail = guard;
}
public void insertAtTail(Node node){
tail.next = node;
tail = node;
}
}
//在数组中查找指定值
public int findKey(int[] a, int count, int key){
if(a == null || count <= 0){
return -1;
}
if(a[count - 1] == key){
return count - 1;
}
int temp = a[count -1];
//a[count - 1] = key 就是哨兵,可以避免每次判断索引值是否越界
a[count - 1] = key;
int i = 0;
while(a[i] != key){
i++;
}
a[count - 1] = temp;
if(i == count - 1){
return -1;
}
return i;
}
7. 数组及链表的几个问题
1. 自定义单链表,校验字符串是否是回文字符串
class Node{
public char data;
public Node next;
}
public class LinkedList{
public Node head;
public Node reverseList(Node tail){
Node curr = head;
Node next = null;
while(curr.next != tail){
next = curr.next;
curr.next = next.next;
next.next = head;
head = next;
}
curr.next = null;
tail.next = head;
head = tail;
return head;
}
public boolean palindrome(){
if(head == null){
return false;
}
if(head.next == null){
return true;
}
if(head.next.next == null){
return head.data == head.next.data;
}
if(head.next.next.next == null){
return head.data == head.next.next.data;
}
Node slow = head;
Node fast = head.next;
while(slow != null && slow.next != null && fast != null && fast.next != null && fast.next.next != null){
slow = slow.next;
fast = fast.next.next;
}
//获取要比较的右半边链表的起始结点
Node right = null;
if(fast.next == null){
//偶数个元素
right = slow.next;
}else{
//奇数个元素
right = slow.next.next;
}
//将左半边链表倒置
Node left = reverseList(slow);
while(left != null && right != null && left.data == right.data){
left = left.next;
right = right.next;
}
//如果是回文字符串,则左右两半链表每个字符都相等,最终都是null
return left.next == null && right.next == null;
}
}
2. 使用数组实现LRU算法
public class LRUArray<T>{
public int capacity = 16;
public T[] datas;
public Map<T,Integer> map = new HashMap<>();
public int count = 0;
public LRUArray(){
datas = new T[capacity];
}
public LRUArray(int cap){
capacity = cap;
datas = new T[capacity];
}
public boolean isFull(){
return count == capacity;
}
public void transferToRight(int idx){
for(int i = idex; i>0;i--){
datas[i] = datas[i - 1];
map.put(datas[i],i);
}
}
public void addToHead(T t){
datas[0] = t;
map.put(t,0);
}
public void access(T t){
if(map.containsKey(t)){
//老元素,将其放到第一位,并移动之前所有的元素
int index = map.get(t);
transferToRight(index);
addToHead(t);
}else{
//新元素
if(isFull()){
//数组已满,则移动之前所有的元素,并将新元素添加到第一位
transferToRight(capacity - 1);
addToHead(t);
}else{
//将之前所有元素右移,并将新元素添加到第一位
transferToRight(count);
addToHead(t);
count++;
}
}
}
}
3. 使用单链表实现LRU算法
public class LRUList<T>{
public class Node{
public T data;
public Node next;
}
public Node head;
public int capacity = 16;
public int size = 0;
public LRUList(){
}
public LRUList(int cap){
capacity = cap;
}
public boolean isFull(){
return size == capacity;
}
public void addHead(T t){
t.next = head;
head = t;
}
public void removeTail(){
if(head == null){
return;
}
if(head.next == null){
head = null;
size--;
return;
}
Node curr = head;
while(curr != null && curr.next != null && curr.next.next != null){
curr = curr.next;
}
curr.next = null;
size--;
}
public void access(T t){
if(head == null){
addHead(t);
size++;
return;
}
if(t == head){
return;
}
Node pre = head;
while(pre != null && t != pre.next){
pre = pre.next;
}
if(pre != null){
//说明t是老元素
pre.next = t.next;
addHead(t);
}else{
//说明t是新元素
if(isFull()){
//当前链表已满,需要删除尾部元素,并将新元素添加到首部
removeTail();
addHead(t);
size++;
}else{
//当前链表未满,直接将新元素添加到首部
addHead(t);
size++;
}
}
}
}
4. 单链表反转
public Node reverseList(Node list){
if(list == null){
return null;
}
Node curr = list;
Node head = list;
Node next = null;
while(curr.next != null){
next = curr.next;
curr.next = next.next;
next.next = head;
head = next;
}
return head;
}
5. 单链表中环的检测
public boolean hasCircle(Node list){
if(list == null){
return false;
}
Node slow = list;
Node fast = list.next;
while(slow != null && slow.next != null && fast != null && fast.next != null && fast.next.next != null){
slow = slow.next;
fast = fast.next.next;
if(slow == fast){
return true;
}
}
return false;
}
6. 合并两个有序单链表
public Node mergeTwoSortedList(Node n1, Node n2){
Node soldier = new Node();
Node curr = soldier;
while(n1 != null && n2 != null){
if(n1.data <= n2.data){
curr.next = n1;
n1 = n1.next;
}else{
curr.next = n2;
n2 = n2.next;
}
curr = curr.next;
}
if(n1 != null){
curr.next = n1;
}
if(n2 != null){
curr.next = n2;
}
return soldier.next;
}
7. 删除单链表的倒数第k个结点
public void deleteLastK(Node list, int k){
if(lsit == null || k < 1){
throw new IllegalArgumentException("参数错误!");
}
Node fast = list;
int i = 0;
while(fast != null && i < k){
fast = fast.next;
}
if(i < k){
throw new IllegalArgumentException("链表长度不够!");
}
if(fast == null){
//说明链表中一共只有k个元素,直接删除head即可
Node next = head.next;
head.next = null;
head = next;
return;
}
Node slow = head;
while(slow.next != null && fast.next != null){
slow = slow.next;
fast = fast.next;
}
//待删除的结点,就是slow.next
Node next = slow.next;
slow.next = next.next;
next.next = null;
return list;
}
8. 求链表的中心结点
public Node gainMiddleNode(Node list){
if(list == null){
throw new IllegalArgumentException("参数错误!");
}
Node slow = list;
Node fast = list;
//注意:这里只要判断fast即可,因为fast走得更快
while(fast.next != null && fast.next.next != null){
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
4. 栈
1. 栈的定义
栈是一种操作受限的线性表.
栈只允许在栈顶插入及删除数据.
先进后出,后进先出.
2. 栈仅支持两种操作: 入栈和出栈
1. 入栈就是在栈顶插入一个数据
2. 出栈就是在栈顶删除一个数据
3. 栈分为 顺序栈 及 链式栈
1. 链式栈
底层用链表实现的栈称为链式栈
2. 顺序栈
底层用数组实现的栈称为顺序栈
- 顺序栈示例
publc class ArrayStack<T>{
public T[] items;
public int n;
public int count;
public ArrayStack(int n){
this.n = n;
items = new T[n];
count = 0;
}
public boolean push(T t){
if(count == n){
//数组空间已满
return false;
}
items[count] = t;
count++;
return true;
}
public T pop(){
if(count == 0){
//栈是空的
return null;
}
T result = items[count-1];
count--;
return result;
}
}
- 支持动态扩容的顺序栈
- 支持动态扩容的顺序栈,只要底层依赖一个支持动态扩容的数组,当栈满了之后,我们就申请一个更大的数组,将原来的数据搬移到新数组中.
4. 出栈和入栈的时间复杂度是O(1)
5. 使用2个栈,可以实现浏览器的 前进及后退 功能
public class Exploer{
//ahead:存储当前正在浏览,以及之前点击浏览的网页
Stack ahead = new Stack();
//back:存储之前点击回退前浏览的网页
Stack back = new Stack();
public boolean canGoBack(){
return !ahead.isEmpty();
}
public boolean canGoAhead(){
return !back.isEmpty();
}
public void click(Page page){
ahead.push(page);
}
public void goAhead(){
if(canGoAhead()){
Page page = back.poll();
ahead.push(page);
}
}
public void goBack(){
if(canGoBack()){
Page page = ahead.poll();
back.push(page);
}
}
}
5. 队列
1. 队列定义
一种线性表.
先进先出,后进后出.
2. 队列支持2种操作: 入队/enqueue 及 出队/dequeue
1. 入队
入队是指将数据放到队列尾部.
2. 出队
出队是指从队列头部获取数据.
3. 队列分为 顺序队列 及 链式队列
1. 链式队列
基于链表实现的队列
2. 顺序队列
基于数组实现的队列
public class ArrayQueue<T>{
private T[] items;
private int n = 0;
//对列头下标
private int head = 0;
//队列尾下标
private int tail = 0;
public ArrayQueue(int capacity){
this.n = capacity;
items = new T[n];
head = tail = 0;
}
public boolean isEmpty(){
return head == tail;
}
public T dequeue(){
if(isEmpty()){
return null;
}
T result = items[head];
head++;
return result;
}
public boolean enqueue(T t){
if(tail == n){
if(head == 0){
//队列已满
return false;
}
//队列不满,移动数组元素,将head之前的位置填满
for(int i = head; i < tail; i++){
items[i - head] = items[i];
}
tail -= head;
head = 0;
}
items[tail] = t;
tail++;
return true;
}
}
3. 为了避免enqueue时候的数组元素搬移,使用循环队列
将顺序队列的首尾相连,形成一个环,就变成了循环对列.
循环队列避免了tail==n时候的数据搬移.
- 循环队列判空条件: head == tail
- 循环队列已满条件: (tail + 1)%n == head
- 循环队列中,tail指针指向的位置不存储数据
public class CircleQueue<T>{
private T[] items;
private int n = 0;
private int head = 0;
private int tail = 0;
public CircleQueue(int capacity){
n = capacity;
items = new T[n];
head = 0;
tail = 0;
}
public boolean isEmpty(){
return head == tail;
}
public boolean isFull(){
return (tail + 1)%n == head;
}
public boolean enqueue(T t){
if(isFull()){
return;
}
items[tail] = t;
tail = (tail + 1)%n;
return true;
}
public T dequeue(){
if(isEmpty()){
return null;
}
T result = items[head];
head = (head + 1)%n;
return result;
}
}
4. 阻塞队列及并发队列
1. 阻塞队列在队列基础上增加了阻塞特性.
- 队列为空,获取数据会被阻塞,直到有数据时候才返回队列头的数据.
- 队列已满,插入数据会被阻塞,直到队列尾部出现空闲位置再插入数据.
2. 并发队列是入队出队属于线程安全的队列.
- 最直接方法是在enqueue和dequeue方法上加锁.
- 利用CAS原子操作,可以实现更细粒度的线程安全.
5. 基于数组实现的队列是有界的,因为数组创建时候就要声明元素个数.当队列已满,后续有新的数据,可以报错也可以直接return,适合需要快速响应的场景.
6. 基于链表实现的队列是无界的,当数据很多,可能导致后续加入的数据很久才被处理,适合对处理时间不严格的场景.
6. 递归
1. 适合用递归解决的问题,满足的3个条件:
1. 待求解问题可以分解为几个子问题的解
2. 带求解问题和子问题,只是数据规模不同,求解思路完全相同
3. 存在递归终止条件,不能无限循环
2. 编写递归代码的关键是写递推公式,寻找终止条件.
3. 递归代码的缺点:
1. 堆栈溢出
2. 重复计算
- 在将问题分解为子问题的过程中,子问题可能是重复的
- 通过使用备忘录/map的形式,将计算过的子问题的结果保存下来,避免子问题重复计算
3. 函数调用耗时多
4. 空间复杂度高
- 递归调用一次就会保持一次现场数据,将栈帧压入栈
4. 将递归代码修改为非递归代码
//以上台阶问题为例
//递归写法
public int step(int n){
if(n == 1){
return 1;
}
if(n == 2){
return 2;
}
return step(n - 1) + step(n - 2);
}
//将递归代码改为非递归代码
public int step(int n){
if(n == 1){
return 1;
}
if(n == 2){
return 2;
}
//slow代表离目标值还有2个台阶,到达slow位置的走法数量
int slow = 1;
//fast代表离目标值还有1个台阶,到达fast位置的走法数量
int fast = 2;
int total = 0;
for(int i = 3; i<=n; i++){
//到达当前台阶,走法数量是 快+慢 走法数量之和
total = slow + fast;
//slow就是到下一个目标值,还有2个台阶
slow = fast;
//fast就是到下一个目标值,还有1个台阶
fast = total;
}
return total;
}
7. 从哪几个方面分析排序算法
1. 排序算法的执行效率
1. 最好时间复杂度, 最坏时间复杂度, 平均时间复杂度
2. 时间复杂度的系数,常数,低阶
- 对于数据规模很小,或时间复杂度相同的不同排序算法,其系数常数及低阶也要考虑进去
3. 比较次数,和交换次数
- 比较元素大小的耗时,要低于交换元素的耗时,两者应区分开来统计
2. 排序算法的内存消耗
1. 原地排序算法
2. 非原地排序算法
需要额外的非常量级别的数据存储空间才能完成排序
3. 排序算法的稳定性
1. 稳定排序算法
待排序数据中存在值相等的元素,排序过后,相等元素的原始顺序不会改变
2. 不稳定排序算法
值相等的元素的原始顺序可能会被改变.
8. 冒泡排序, 插入排序, 选择排序
1. 冒泡排序
public void bubbleSort(int[] a){
if(a == null || a.length <= 1){
return;
}
int count = a.length;
boolean matched = false;
int temp;
//冒泡排序需要最多比较n次
for(int i = 0; i < count; i ++){
matched = false;
for(int j = 0; j< count - 1 - i; j++){
if(a[j] > a[j+1]){
temp = a[j];
a[j] = a[j + 1];
a[j + 1] = temp;
matched = true;
}
}
//如果某次比较没有可交换的相邻元素,说明所有元素都是有序的,退出循环
if(!matched){
break;
}
}
}
1. 冒泡排序是原地排序,因为不需要申请非常量级的额外存储空间
2. 冒泡排序是稳定排序算法,对大小相等的元素不做交换
3. 冒泡排序最好时间复杂度是O(1),最坏时间复杂度是O(n^2),平均时间复杂度是O(n^2)
2. 有序度
1. 有序度是指数组中具有有序关系的元素对的个数
2. 逆序度是数组中逆序元素对的个数
3. 完全有序的数组的有序度,称为 满有序度
4. 逆序度 = 满有序度 - 有序度
3. 插入排序
public void insertSort(int[] a){
if(a == null || a.length <= 1){
return;
}
int count = a.length;
int value;
int j;
for(int i = 1; i< count; i++){
//当前要和已排序部分笔记的元素值
value = a[i];
//已排序部分末尾元素的索引值
j = i - 1;
for(; j >= 0; j--){
if(a[j] > value){
//将该元素后移一位
a[j + 1] = a[j];
}else{
break;
}
}
//将value插入
a[j + 1] = value;
}
}
1. 插入排序是原地排序
2. 插入排序是稳定排序
3. 插入排序的时间复杂度和冒泡排序一样,是O(n^2)
4. 选择排序
选择排序思路类似插入排序,也是将整个数组划分为已排序区间和未排序区间. 选择排序每次从未排序区间中找到最小的元素,将其和未排序区间的第一个元素进行交换.
public void selectSort(int[] a){
if(a == null || a.length <= 1){
return;
}
int count = a.length;
int minIndex;
int temp;
for(int i = 0; i< count - 1; i++){
minIndex = i;
for(int j = i; j< count; j++){
if(a[j] < a[minIndex]){
minIndex = j;
}
}
//交换元素
temp = a[i];
a[i] = a[minIndex];
a[minIndex] = temp;
}
}
1. 选择排序是原地排序
2. 选择排序是不稳定排序
- 当min元素和未排序区间的首元素进行交换,可能导致相同值的元素原始顺序改变. 如 6,5,4,6,3,1,2
3. 选择排序的时间复杂度是冒泡排序一样,是O(n^2)
5. 插入排序和冒泡排序的时间复杂度是相同的,为什么插入排序比冒泡排序应用更广
因为在循环中,插入排序仅需要1个赋值语句,而冒泡排序需要3个. 冒泡排序的运算量更大.
9. 归并排序
归并排序用到了分治思想.
public void mergeSort(){
int [] a = **;
mergeSort(a, 0, a.length - 1)
}
public void mergeSort(int[] a , int startIndex, int endIndex){
if(startIndex >= endIndex){
return;
}
int middleIndex = (startIndex + endIndex) / 2;
mergeSort(a, startIndex, middleIndex);
mergeSort(a, middleIndex + 1, endIndex);
merge(a, startIndex, middleIndex, middleIndex + 1, endIndex)
}
public void merget(int[] a, int s1StartIndex, s1EndIndex, s2StartIndex, s2EndIndex){
int[] temp = new int[s2EndIndex - s1StartIndex + 1];
int i = 0;
int s1 = s1StartIndex;
int s2 = s2StartIndex;
while(s1 <= s1EndIndex && s2 <= s23EndIndex){
if(a[s1] <= a[s2]){
temp[i++] = a[s1++];
}else{
temp[i++] = a[s2++];
}
}
while(s1 <= s1EndIndex){
temp[i++] = a[s1++];
}
while(s2 <= s2EndIndex){
temp[i++] = a[s2++];
}
for(int i = s1StartIndex; i<= s2EndIndex; i++){
a[i] = temp[i - s1StartIndex];
}
}
1. 归并算法是稳定排序
2. 归并算法不是原地排序,因为'合并'过程中要创建临时数组
3. 归并算法的时间复杂度是O(nlogn)
1. 归并算法每一层,都要处理所有数组元素,所以每一层复杂度是O(n)
2. 归并算法的层数,是O(logn).
3. 归并算法的时间复杂度用乘法法则就是O(nlogn)
4. 归并算法的空间复杂度是O(n)
1. 因为合并操作,需要创建临时数组,每一层创建的多个临时数组的总大小是n
2. 空间复杂度表示在运行过程中最大的内存消耗,每一层计算完毕后都会及时释放内存,并不会持续占用.
3. 归并排序是递归调用,每次调用空间消耗是常量级的,所以函数调用栈的空间消耗是O(logn)
4. 总体空间复杂度是. O(n + logn) -> O(n)
10. 快速排序
快速排序也用到了分治思想.
public void quickSort(){
int[] a = **
quickSort(a, 0, a.length - 1);
}
public void quickSort(int[] a, int startIndex, int endIndex){
if(startIndex >= endIndex){
return;
}
int partition = partition(a, startIndex, endIndex);
quickSort(a, startIndex, partition - 1);
quickSort(a, partition + 1, endIndex);
}
public int partition(int[] a, int startIndex, int endIndex){
int max = a[emdIndex];
int result = startIndex;
for(int i = startIndex; i <endIndex; i++){
if(a[i] < max){
swap(a, result, i);
result++;
}
}
swap(a, result, endIndex);
result result;
}
public void swap(int[] a, int i1, int i2){
if(i1 == i2){
return;
}
int temp = a[i1];
a[i1] = a[i2];
a[i2] = temp;
}
1. 快速排序是原地排序.
2. 快速排序不是稳定排序. 如: 1,2,3,4, 7 ,9,7,8, 6 . 最后7和6交换后,导致2个7的原始顺序改变.
3. 快速排序的时间复杂度是 O(nlogn). 空间复杂度是 O(logn)
4. 使用快速排序,求数组中第K个元素/第K小元素.
1. 时间复杂度是O(n)
2. 每一次快速排序:
- 如partion + 1 = K, 则 a[partion]就是第K小元素
- 如果 partion + 1 < K , 说明要在 partion后面的元素中继续找
- 如果 partion + 1 > K , 说明要在 partion前面的元素中继续找
- 假定每次partion都在中间, 则复杂度是 O(n + n/2 + n/4 + ***) -> O(2n) -> O(n)
5. 归并排序不是原地排序,其空间复杂度是O(n),所以没有快速排序应用广泛.
11. 线性排序 : 桶排序, 计数排序, 计数排序, 这几种排序算法的时间复杂度是线性的,被称为线性排序.
12. 桶排序
- 首先统计一组数据的min及max,得到数据的范围,根据范围大小将所有数据划分为k个桶. 则每个桶里有 n/k 个数据.
- 对每个桶进行快排,每个桶的时间复杂度是 n/k * log(n/k)
- k个桶,则总体时间复杂度是 k * n/k*log(n/k) = nlog(n/k)
- 当k/桶的数量和n接近,则log(n/k)接近1,总体时间复杂度接近 O(n)
- 桶排序比较适用于外部排序,就是数据存储于外部磁盘中,数据量很大,而内存有限,不能一次性加载到内存中处理.
13. 计数排序
计数排序是桶排序的一种特殊情况,适用于数据范围不大的情况.
直接将数据分为k个桶.k就是数据范围/跨度.
每个桶内存放的都是相等的数据,省略了桶内排序的时间.
public void countSort(int[] a){
if(a == null || a.length <= 1){
return;
}
//获取数组长度
int n = a.length;
//记录数组元素最大值
int max = a[0];
for(int i=1;i<n;i++){
if(a[i] > max){
//修改最大值
max = a[i];
}
}
//根据最大值创建多个桶. 0 -> max 是 max+1 个桶.
int[] c = new int[max + 1];
for(int i = 0; i< n; i++){
//统计每个桶内元素数量
c[a[1]]++;
}
for(int i = 1; i <= max; i++){
//每个桶内的值和前一个值累加
c[i] = c[i - 1] + c[i];
}
int[] r = new int[n];
for(int i = n-1; i >= 0; i--){
//c[a[i]代表当前值的元素,在原始数组中是第几个
r[c[a[i]] - 1] = a[i];
//用掉一个元素后,对应桶内的值要减一
c[a[i]]--;
}
//将临时数组内的值赋值到原始数组
for(int i = 0; i < n; i++){
a[i] = r[i];
}
}
14. 基数排序
例如手机号码有11位,数据范围太大了,不适合桶排序,计数排序这种排序算法.
但是手机号码的位数都是相同的,而且高位排序确定后,低位的大小关系不影响总体排序.
而且每一位的数据范围并不大.
- 对于位数相同,或可以补全为位数相同
- 数据范围太大的一组数据
- 且高位先后顺序确定情况下,低位大小顺序不影响实际排序
- 可以从最后一位开始,对所有的数据进行排序. 假设有k位,则进行k次排序即可.
- 每次对1位进行排序,可以使用桶排序或计数排序完成,复杂度是O(n)
- 对k位进行排序,复杂度就是O(kn)
15. 如何实现一个高性能的通用排序函数
1. 线性排序的时间复杂度是O(n),但对数据要求很高,不适用与普遍场景.
2. 为了兼顾任意数据规模的排序,一般首选时间复杂度为O(nlogn)的排序算法.
3. 堆排序,快速排序,归并排序的时间复杂度都是O(nlogn).
1. Java使用堆排序实现排序函数
2. C语言使用快排实现排序函数
- 归并排序的使用场景并并不多,因为归并排序并不是原地排序,merge过程最大空间复杂度是O(n).
- 实际软件开发中,排序算法是否是原地排序算法是一个非常重要的选择标准.
4. 如何优化快速排序.
1. 快速排序高性能关键,在于分区点的选择.如果分区点正好是中值附近性能最好,如果是极值附近,时间复杂度就会劣化为O(n^2).
2. 为了选择合理的分区点
- 三数取中法
- 从要排序区间的首,尾,中间各取一个数据.取中间值作为初始分区点.
- 如果数组区间太大,可以继续优化为 五数取中 , 十数取中.
- 随机法
- 随机法是在要排序的区间中随机选一个元素做为初始分区点.不能保证每次都取到最好,但是也不容易取到极值.
16. 二分查找
1. 二分查找的时间复杂度: O(logn)
O(logn)是非常高效的,甚至比O(1)更高效.因为我们表示时间复杂度会忽略系数,常数,很多时候O(1)可能是100,100,远比log(n)更大
2. 二分查找的非递归实现
public int bsearch(int[] a, int value){
int n = a.length;
int low = 0;
int high = n - 1;
int mid;
while(low <= high){
mid = low + ((high - low) >> 1);
if(a[mid] == value){
return mid;
}if(a[mid] < value){
low = mid + 1;
}else{
high = mid - 1;
}
}
return -1;
}
3. 二分查找的递归实现
public int bsearch(int[] a, int value){
int n = a.length;
bsearchInternally(a, 0, n - 1, value);
}
public int bsearchInternally(int[] a, int low, int high, int value){
if(low > high){
return -1;
}
int mid = low + ((high - low) >> 1);
if(a[mid] == value){
return mid;
}else if(a[mid] < value){
return bsearchInternally(a, mid + 1, high, value);
}else{
return bsearchInternally(a, low, mid - 1, value);
}
}
4. 二分查找的局限性
- 二分查找依赖于数组.
- 链表不适用于二分查找,因为数组支持按照下标访问数据,时间复杂度是O(1),而链表依据位置访问结点的时间复杂度是O(n)
- 二分查找只适用于静态有序数据.
- 如果一组数据涉及经常的增加删除,则每次二分查找前都需要进行排序,时间成本太高.当然频繁插入删除,任何排序算法的时间复杂度都很高.
- 数据量太大,也不适合二分查找
- 二分查找底层依赖数组,数据量太大,数组申请时候容易OOM.
- 如何数据之间的比较非常耗时,那无论数据量大小,都推荐二分查找.避免相邻元素间的频繁比较,直接通过下标查找元素后和目标值比较.极大降低比较次数.
5. 二分查找的变体
1. 查找第一个值等于给定值的元素
public int bsearch(int[] a, int target){
int low = 0;
int high = a.length - 1;
int mid;
while(low <= hign){
mid = low + ((high - low) >> 1);
if(a[mid] > target){
high = mid - 1;
}else if(a[mid] < target){
low = mid + 1;
}else{
if(mid == 0 || (a[mid - 1] != target)){
return mid;
}
high = mid - 1;
}
}
return -1;
}
2. 查找最后一个值等于给定值的元素
public int bsearch(int[] a, int target){
int low = 0;
int high = a.length - 1;
int mid;
while(low <= high){
mid = low + ((high - low) >> 1);
if(a[mid] > target){
high = mid - 1;
}else if(a[mid] < target){
low = mid + 1;
}else{
if(mid == a.length - 1 || (a[mid + 1] != target)){
return mid;
}
low = mid + 1;
}
}
return -1;
}
3. 查找第一个值大于等于给定值的元素
public int bsearch(int[] a, int target){
int low = 0;
int high = a.length - 1;
int mid;
while(low <= high){
mid = low + ((high - low) >> 1);
if(a[mid] < target){
low = mid + 1;
}else{
if(mid == 0 || a[mid - 1] < target){
return mid;
}
high = mid - 1;
}
}
return -1;
}
4. 查找最后一个值小于等于给定值的元素
public int bsearch(int[] a, int target){
int low = 0;
int high = a.length - 1;
int mid;
while(low <= high){
mid = low + ((high - low) >> 1);
if(a[mid] > target){
high = mid - 1;
}else{
if(mid == a.length - 1 || a[mid + 1] > target){
return mid;
}
low = mid + 1;
}
}
return -1;
}