白话算法设计分析
前述: 本来不太想写这个话题的,算法应该是一种需要专研思考的东西。后面想想,记录也应该是成长的一部分。
tip: 记录没有太多做题记录,一般都是经典题型的思路
这也是为什么叫白话算法了,但这玩意吧,需要沉淀!
话不多说。依旧是从排序开始。这里应该需要掌握算法的时间、空间复杂度的求解,3个渐进符号,上界,下界,渐进的意思。
比较型排序算法
比较型,可以理解为,这种排序算法两两元素需要比较排序才能得到的。值得一提的是,这种排序算法的下界是nlgn,也就是说,任何基于比较的排序算法最好也就只能渐进O(nlgn),这里可以使用决策树模型证明,证明就自己体会吧。
插入排序
插入排序的思路也很简单,可以想象成打扑克,现在你手上初始有一张牌,然后从牌堆抓取一张,为保证手上的牌是有序的,你需要去比较现在手上的牌,然后确定插入的位置,直至牌堆空了。
Code
public static void INSERTION_SORT(Integer[] nums){
for(int i = 1;i<nums.length;i++){
int key = nums[i]; // 记录当前的位置
int j = i-1; // 游标,向前游
while(j>=0&&nums[j]>key){ // 首先不越界,然后对比手中的牌
nums[j+1] = nums[j]; // 可以交换就换
j=j-1; //继续游动
}
nums[j+1] = key; //最后插入
}
}
这里需要注意的是,一般的算法都会介绍,序列都是以1开头的,而这里还是使用的是0开头。一般我都会去刻意转换一下。
这里的思路需要知道,这里并没有一直比较在交换,而是看见合适的地方就先交换再说。最后在一个总的交换。慢慢体会一下。相比于确定位置在交换的话,这种不需要头痛的考虑数组越界问题。伪代码可以参考一下算法导论的代码,如果没记错出处的话。
时间,空间复杂度分析
相信这个都比较熟悉,首先是交换的1*c 这里的c是一个常数因子,而循环的话需要看循环的次数,这里的话是n,和 i 次,这里的i随着n变化而变化,看一下应该会是一个上三角矩阵的类型图,常数因子依旧是一个c这里的c很可能和上面的不同,不过影响算法的复杂度不会是他,但也有时候会是他,后面就有一些案例追求极致。
按照取大头的思路,可以得到渐进n^2,我不知道为什么这个符号打不出,但不是想当然的O(n^2),后面平均也可以看作是O(n^2)
空间复杂度:因为是原址排序,所以是O(1) 的
总结一下,时间:O(n^2) 空间:O(1)
后续的分析将快速带过
冒泡排序,选择排序不做赘述,他们的复杂度与插入差不多,也就是个常数因子的事。
归并排序
要了解归并排序就必须要掌握分治技术,而一般的分治都是递归实现,这里的递归需要练上百遍甚至千遍万遍,原因无他,就是很重要。循环可能都可以掌握,递归需要明确自己在做是什么,后面再补。回到正题。
分治: 首先就是需要先将问题划分成多个子问题,然后求解子问题,最后合并子问题的解得到问题解。这里和后面的动态规划很像,需要区别开。
归并排序的思路就是先将这个序列化划分成两个平均的子序列,这里有可能长度不等,不过没关系,然后排序子问题,最后组合起来,以此类推,要获得子问题的解,就需要子子问题的解,这就构成了递归。
Code
public static void MEGER_SORT(Integer[] nums,int left,int right){
if(left<right){
int mid = left+((right-left)>>1);
MEGER_SORT(nums,left,mid);
MEGER_SORT(nums,mid+1,right);
// 合并
Meger(nums,left,mid,right);
}
}
private static void Meger(Integer[] nums,int left,int mid,int right){
int p1 = left;
int p2 = mid+1;
Integer[] helper = new Integer[right-left+1];
int i = 0;
while(p1<=mid && p2<=right){
helper[i++] = nums[p1]<nums[p2]?nums[p1++]:nums[p2++];
}
while(p1<=mid){
helper[i++] = nums[p1++];
}
while(p2<=right){
helper[i++] = nums[p2++];
}
for(i = 0;i<right-left+1;i++){
nums[left+i] = helper[i];
}
}
解释就是先定义base element也就是基本情况,然后求解,这也就是递归出口,这里可以改成 left == right 然后直接返回就好。
这种情况就是两个下标指向同一个数,所以就是已经排好的序列。
这里在提一嘴,对于排序问题可以总结为:
有一个S{a1,a2…an}是无序的,需要返回一个序列使得ai<=aj,i<j;
而当没有排好的情况,我就先将他们分为平均的两部分,然后分别求解两个子问题,最后合并。
处理递归问题,必须要清除,这个函数的作用是什么,这样才能不晕。合理使用递归。而且,递归属于自顶向下的处理方式,他会避开一些不必要的子问题,也就是只会处理必要的子问题,而自底向上就不行,这是优势,但劣势很明显,就是有重叠子问题也会一直重复解。后话后话。
归并排序并没有重叠子问题,每要求解一个子问题的时候,都会带出新的子问题,所以不难看出,子问题的个数是n,递归树是lgn,所以他的时间复杂度是O(nlgn).
在使用递归的时候,不需要多想,只要清楚宏观上的解法,细节递归都帮你做了。归并就是,大的拆成两个小的左右,分别求左,求右,最后合并,不需要考虑细节是什么。
有了base element就OK。
处理base element,合并
这里使用的直接就是双指针,算法导论没记错的话是使用哨兵和准确的工作步长做的。各有各的好处,基本都只是常数因子的不同。
值得注意的是,这里使用了辅助空间,而且是n的,他的空间复杂度是O(n),需要注意的的是这个排序算法是稳定的。
为了体现常数因子的处理效果,加一个顺序统计量进来
这里就用一个顺序统计量的知识点,在序列中返回最大值和最小值。
返回最大值
Code
private static int maximum(Integer[] nums){
if(nums == null || nums.length <=0){
throw new NullPointerException("无数据");
}
int key = nums[0];
for(int i = 1;i<nums.length;i++){
if(nums[i]>key){
key = nums[i];
}
}
return key;
}
返回最小的话,也是差不多的,所以他们的时间复杂度都是O(n)
然后这里需要同时返回最大最小的元组,所以,这里需要比较2(n-1)次,步骤是,当前的与最小的比,是否替换,当前的与最大的比,是否替换,而总共需要遍历(n-1)个元素。
这里微调一下,首先判断数组的长度是奇数还是偶数,奇数的话默认最大最小都是第一个元素,如果是偶数,先将前两个作比较,然后大的为最大,小的为最小。
后面都是两个两个遍历。先将两个比较一下,大的和最大值比,小的和最小值比。
神奇的事就发生了。他们的比较次数是3/2(n-1)+c
可以想象一下,确定2个数的情况,第一种需要4次,而后者仅需要3次,这就是常数因子的决策结果。他们都是渐进O(n)的
Code
public static void getMinAndMax(Integer[] nums){
if(nums == null || nums.length <=0){
throw new NullPointerException("无数据");
}
int begin = 1;
int max = nums[0];
int min = nums[0];
if(nums.length%2==0) {
max = Math.max(nums[0], nums[1]);
min = Math.min(nums[0], nums[1]);
begin = 2;
}
for(int i = begin;i<nums.length;i+=2){
int n1 = nums[i];
int n2 = nums[i+1];
int t_max = Math.max(n1,n2);
int t_min = Math.min(n1,n2);
if(t_max>max){
max= t_max;
}
if(t_min<min){
min = t_min;
}
}
System.out.println("max : "+max+" min : "+min);
}
回归正题,归并排序是分治策略很好的案例,而且分治思想是非常重要的。后话,反正需要好好琢磨琢磨。
堆排序
这里需要先了解一下堆这个数据结构,这个数据结构是属于很底层的数据结构了,运用的地方也是相当广发,操作系统中的调度算法中就有这个的存在。
这里不做赘述,可以把他想象成为一颗有约束的二叉树,这颗树和排序树有些相近,但是需要区别开。
这里使用数组来作为他的存储结构。
最小堆(小顶堆):每颗子树都要满足所有的孩子结点都要大于等于父节点,并且是一颗完全二叉树。
而堆排序就是运用这一个特点,只需要有两个维护机制就可以一直保持堆的特点。一个是insert,也就是插入,一个是维护,也就是保持特点。
因为底层存储结构是数组,所以,需要一个size来表示堆的大小,在size外面的则不属于堆。
Code
private static void heapSort(Integer[] nums){
for(int i = 0;i<nums.length;i++){
insertHeap(nums,i);
}
int max_size = nums.length-1;
while (max_size>0){
NumberArrayUtil.swap(nums,max_size,0);
updateHeap(nums,--max_size);
}
}
// 建堆
private static void insertHeap(Integer[] nums,int index){
while(nums[index]>nums[(index-1)/2]){
NumberArrayUtil.swap(nums,index,((index-1)/2));
index = (index-1)/2;
}
}
// 维护堆
private static void updateHeap(Integer[] nums,int max_size){
int index = 0;
int left = 1;
while(left<=max_size){
int max_index = left+1<max_size&&nums[left+1]>nums[left]?left+1:left;
max_index = nums[index]>nums[max_index]?index:max_index;
if(max_index==index){
break;
}
NumberArrayUtil.swap(nums,index,max_index);
index = max_index;
left = index*2+1;
}
}
两个维护特征就不解读了,只需要注意这里是从0开始,而不是1
如果是1开始的话,本身为i,则左孩子为2*i,而右孩子就是2*i+1,也就是left+1;
堆排序的时间复杂度是渐进O(nlgn)可以说他是理想状态下非常好的排序了,奈何需要事实说话,快排在测试中还是优于他的。这里需要涉及随机算法和随机算法分析,一言难尽。
时间复杂度O(nlgn) 空间复杂度O(1),最坏的也是一样。但常数因子不行哦。
堆这个数据结构可以搭建一个优先调度算法,其实就是维护一个优先队列,用堆来搭建优先队列是常见的。
Code
/**
* Created by IntelliJ IDEA.
*
* @Author : zushiye
*/
/**
* 优先队列实现
* insert(S,x) 元素x
* insert(S,i,x) 具体位置插入
* maximum(S) 返回 最大key的数据
* extract-max(S) 返回 最大key的数据 并且删除这个数据
*
*/
public class PriorityQueue<T> {
static class Node<T>{
public int key;
public T data;
public Node(int key, T data) {
this.key = key;
this.data = data;
}
public Node() {
}
@Override
public String toString() {
return "Node{" +
"key=" + key +
", data=" + data +
'}';
}
}
private Node<T>[] nodes; // 数据
private int heapSize = 0; // 堆大小
public PriorityQueue(){
nodes = new Node[10];
}
public PriorityQueue(int len) {
nodes = new Node[len];
}
public T maximum_data(){
return nodes[0].data;
}
public int maximum_key(){
return nodes[0].key;
}
// 新增数据
public void insert(int key, T x){
int index = heapSize++;
insert(index,key,x);
}
// 原数据修改
public void insert(int i,int key,T x){
if(i>heapSize){
throw new IndexOutOfBoundsException("堆越界异常");
}
if(i>=nodes.length){
nodes = add_size();// 扩容
}
nodes[i] = new Node<>(key,x);
build_heap(i);
uphold_heap(i,heapSize);
}
public T extract_max(){
if(heapSize == 0){
throw new NullPointerException("无数据");
}
T result = nodes[0].data;
swap(0,--heapSize);
uphold_heap(0,heapSize);
nodes[heapSize] = null;
return result;
}
// 扩容
private Node<T>[] add_size(){
Node<T>[] newNodes = new Node[nodes.length*2];
System.arraycopy(nodes, 0, newNodes, 0, nodes.length);
return newNodes;
}
// 创建堆
private void build_heap(int i){
while(nodes[i].key>nodes[(i-1)/2].key){
swap(i,(i-1)>>1);
i = (i-1)/2;
}
}
// 维护堆
private void uphold_heap(int i, int heapSize){
int left = 2*i+1;
while(left<heapSize){
int max_index = left+1<heapSize && nodes[left+1].key>nodes[left].key?left+1:left;
max_index = nodes[max_index].key>nodes[i].key?max_index:i;
if(max_index == i){
break;
}
swap(max_index,i);
i = max_index;
left = i*2+1;
}
}
private void swap(int i,int j){
Node<T> temp = nodes[i];
nodes[i] = nodes[j];
nodes[j] = temp;
}
public void look_heap(){
for(int i = 0;i<heapSize;i++){
System.out.println(nodes[i]);
}
}
}
好的程序等于好的算法+好的数据结构。
优先队列怎么使用呢,这里的key就好比事件的优先值,而data就是事件,也称卫星数据。只需要维护一个最大堆就可以做到事件优先执行,插入了。
tip: 分析一下优先队列操作的时间复杂度。
快速排序
终于到了这个常用的排序了,这里不会直接使用创造者的第一代版本的代码,但也不会一下很复杂。相队来说,白话文。
首先,不上升到分治策略,只需要简单递归,第一版
快速排序,这个排序是无法见名知意的。但是不得不说,他真的很快。
首先,定义一个基准值,这里直接了当,就是最后一个为什么呢,后面就明白了。
其次,定义一个小于基准数区域的右边界,初始为-1(从零开始,所以-1)表示没有比基准小的了
然后遍历0~最后的前一位,如果小于基准的,就让右边界向右移动一位,然后交换两个数,知道遍历结束。
最后交换右边界后一位和基准位。
然后递归排序以基准数的左边和右边。
虽然在最后一步还是用到了分治,但懂得都懂。这里就没有什么大区域了,这也是为什么直接定在最后一位了,但这样是不是可能很糟糕呢,说不定呢。所以将基准元素放中间也是一样的,多定义一个大区域,初始还是没有,然后遍历数组,如果是基准下标,就直接跳过,继续处理。
Code_one
private static void quickSort(Integer[] nums,int low, int high){
if(low<high){
int mid = partition(nums,low,high); // 定下基准元素
quickSort(nums,low,mid-1);// 左边
quickSort(nums,mid+1,high);// 右边
}
}
// 基准元素在所有元素一边
private static int partition(Integer[] nums,int low, int high){
/**
* 核心思想:
* 选定一个基准坐标
* 选定左区域为空区域,这里为了简单,直接认为就是第一个元素为左区域
* 然后遍历数组
* 遇见小的,就将左区域扩大一个并且将这个小的放进这个区域里面,也就是交换当前的数据和小区域的数据
* 如果大于的话就不需要管,反正后面将基准数与小区域后面的的一个数交换就可以了
*
* 也就是说,基准元素定下来的位置只和小区域有关
*/
// 获取该元素在 low~high 中的 index !base case
int base_element = nums[high]; // 基准坐标
int left = low; // 小于的游标
for(int i = low;i<high;i++){
if(nums[i]<=base_element){
NumberArrayUtil.swap(nums,left++,i);
}
}
NumberArrayUtil.swap(nums,left,high); // 交换游标位置和 基准元素位置
return left;
}
这里的partition就是确定基准元素的位置的函数,返回的就是排好序的坐标。
Code_two
// 基准元素在中间
private static int partition_02(Integer[] nums,int low,int high){
int mid = low+(high-low)/2;
int base_element = nums[mid];
int left = low-1;
int right = high+1;
for(int i = low;i<=high;i++){
if(i == mid){
break;
}
if(nums[i]<=base_element){
left++;
NumberArrayUtil.swap(nums,left,i);
}else{
right--;
NumberArrayUtil.swap(nums,right,i);
}
}
// 两种形式都可以
// int i = low;
// while(left<right&& i<=high){
// if(nums[i] == base_element){
// break;
// }
// if(nums[i]<=base_element){
// left++;
// NumberArrayUtil.swap(nums,left,i);
// }else{
// right--;
// NumberArrayUtil.swap(nums,right,i);
// }
// i++;
// }
NumberArrayUtil.swap(nums,left,high); // 交换游标位置和 基准元素位置
return left;
}
所以,下面的就是常见的快速排序了,其实有一个更简单的方法,而且效果好于这个的代码,就是随机partition,实现非常简单,使用第一种的partition方法。
Code_three
private static int random_partition(Integer[] nums,int low, int high){
int index = new Random().nextInt(high+1-low)+low;
NumberArrayUtil.swap(nums,index,high);// 交换
return partition(nums,low,high);
}
这里的NumberArrayUtil是自己写的算法调试工具包,函数作用就是交换两个数的位置。简简单单。
时间复杂度O(nlgn)空间复杂度O(1)
这还是最优的,最坏的话时间是O(n^2)
但就是比堆排序好,欸,好气好气。
基于比较的就结束了。
后面就是不是比较的了,这个下界证明的话,说实话,看一下决策树就明白了,数学证明然后就可以懂了。
主要就是基数排序,计数排序,桶排序。
后面会巩固递归和分治,这俩思路基本涵盖大部分问题,这两个不会,直接gg