「左程云-算法与数据结构笔记」| P3 认识复杂度与简单排序算法

351 阅读9分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第一天,点击查看活动详情

最近在看左神的数据结构与算法,考虑到视频讲的内容和给出的资料的PDF有些出入,不方便去复习,打算出一个左神的数据结构与算法的笔记系列供大家复习,同时也可以加深自己对于这些知识的掌握,该系列以视频的集数为分隔,今天是第一篇:P3|认识复杂度与简单排序算法。

一、时间复杂度

时间复杂度需要讲的不多,就简单讲讲一些基本的规则

  1. 对于大O算法,我们在计算时间复杂度的时候,只需要取最高阶的就行。比如 An² + Bn + C,我们只需要取 An²,其中常数项省略,即O(n²)
  2. 对于相同的时间复杂度,不能直接比较,也不能比较常数项,比如O(3N)O(N)O(3N)不一定就没有 O(N)快,因为3只是表示三次操作,但是操作的耗时并不在考虑范围之内,如果这三次操作是位运算,那么时间将会大大加快
  3. 因此对于相同时间复杂度的两个算法,我们通常使用的方法就是实际去测

二、选择排序

	public static void sort(int[] array){  
	    for (int i = 0; i < array.length; i++) {  
	        int maxNumIndex = 0;  
	        for (int j = 0; j < array.length - i; j++) {  
		        // 找到最大数的索引
	            maxNumIndex = array[j] > array[maxNumIndex] ? j : maxNumIndex;  
	        }  
	        // 把目前这个循环的最大数放在本次循环的最后面
	        swap(array,maxNumIndex,array.length - 1 - i);  
	    }  
	}

1、实现思路

  • 首先遍历索引为 0 - N-1 的数组,把所有的值依次去做比较,找到最大值的索引并且把它记录下来,使用 swap 函数交换索引为 N-1 和最大值索引,此时最大值就在最后一个,即N-1 位置了
  • 第二次遍历索引为 0 - N-2 的数组,同样的去找到最大值的索引,并与 N-2 索引的值做交换,此时第二大的数就来到了 N-2 的位置
  • …………

2、时间复杂度

  • 在这个过程中:
    • 去看每个位置上的值的次数为:N + N-1 + N-2 + ... + 1
    • 去比较相邻位置的值的次数 为:N + N-1 + N-2 + ... + 1
    • 交换两索引位置的值的次数为:N
  • 因此,根据时间复杂度的算法,我们只保留最高次项,并省略常数项,得到时间复杂度为O(n²)

三、冒泡排序

	public static void sort(int[] array){  
	    for (int i = 0; i < array.length; i++) {  
	        for (int j = 0; j < array.length - i - 1; j++) {  
	            if (array[j] > array[j + 1]){  
	                swap(array, j,j + 1);  
	            }  
	        }  
	    }  
	}

1、实现思路

  • 冒泡排序与选择排序的类似之处在于:它们都是依次遍历 0 - N-1、0 - N-2,然后让 N-1、N-2…… 依次成为有序的数
  • 二者的不同点在于:
    • 选择排序是在每次遍历中,找到最大数的索引,然后交换最后一位与最大数
    • 冒泡排序是在每次遍历中,每次与相邻的数做比较,大的数往后移,这样做完所有的比较与交换,最大的数也自然来到了最后一位

2、时间复杂度

  • 在这个过程中:
    • 冒泡排序一共要进行 N-1 次循环
    • 每次循环都要进行 N-1 次比较
    • 因此一共比较了 N-1 + N-2 + N-3 + …… + 1
  • 因此,根据时间复杂度的算法,我们只保留最高次项,并省略常数项,得到时间复杂度为O(n²)

四、异或

1、异或运算基本规则

  1. 异或运算(^)的运算规则为:比较两个数的二进制数,相同为0,不同为1。
  2. 异或运算还可以理解为:无进位相加,即 1 + 1 = 0,但是不进位
  3. 按照上述的基本运算法则我们可以得出一些简单的结论:
    1. n ^ 0 = n (其中 n 为任意数)
    2. n ^ n = 0 (因为所有的二进制位相同,因此得到的为 0)
    3. a ^ (b ^ c) = (a ^ b) ^ c (即 异或运算满足交换律和结合律)

2、交换两个数的值

	// 获取两个数的值
	Scanner scanner = new Scanner(System.in);  
	System.out.print("请输入第一个数:");  
	int num1 = scanner.nextInt();  
	System.out.print("请输入第二个数:");  
	int num2 = scanner.nextInt();  
	System.out.println("交换前 num1 = "+ num1 + ",num2 = " + num2); 

	// 核心代码 
	num1 = num1 ^ num2;  
	num2 = num1 ^ num2;  
	num1 = num1 ^ num2;  

	System.out.println("交换后 num1 = "+ num1 + ",num2 = " + num2);
  1. 这就是不通过中间变量去交换两个值的方法,为弄懂为什么可以这么写,我们不妨手动调试一下
    1. 假设 num1 = a num2 = b
    2. 那么 num1 = num1 ^ num2 = a ^ b
    3. num2 = num1 ^ num2 = a ^ b ^ b = a ^ (b ^ b) = a ^ 0 = a
    4. num1 = num1 ^ num2 = a ^ b ^ a = b ^ (a ^ a) = b ^ 0 = b
  2. 通过手动调试,我们发现,两个数确实通过三次异或运算交换了值,但是这种交换值的方法存在一些特殊的情况:
    1. 如果两个数相等,而且两个数的地址不同
      1. 如果 num1 = num2 = a (a ≠ 0)
      2. 那么 num1 = num1 ^ num2 = 0
      3. num2 = num1 ^ num2 = 0 ^ num2 = a
      4. num1 = num1 ^ num2 = 0 ^ num2 = a
      5. 其实我们发现,还是能够交换的
    2. 而如果两个数相等,而且两个数为同一个地址,此时就会出现都变成 0 的情况

3、找到出现奇数次数的数

题目:在一个数组里面有一个数出现了奇数次,其他的数都出现的次数为偶数次,现要找到这个数
	public static int getOddNum(int[] array){  
	    int result = 0;  
	    for (int num : array) {  
	        result = result ^ num;  
	    }  
	    return result;  
	}
实现思路:对于出现偶数次的数字,两两做异或运算得到的结果为零,因此遍历数组中的所有数,进行异或,如果是出现偶数次的数字,都会因为异或运算变成零。而对于出现奇数次的数,就会留下来。

4、找到出现奇数次数的两个数

题目:在一个数组里面有两个数出现了奇数次,其他的数都出现的次数为偶数次,现要找到这两个数
	int[] array = new int[]{1,2,2,3,3,4,5,5,6,6,7,7,8,8,9,9,4,5,5,6,6,7,7,8,8,11,13,11};  
	int temp = 0;  
	for (int i : array) {  
	    temp = temp ^ i;  
	}  
	temp = temp & (~temp + 1);  
	int result1 = 0;  
	int result2 = 0;  
	for (int num : array) {  
	    if ((num & temp) == 0){  
	        result1 = result1 ^ num;  
	    }else {  
	        result2 = result2 ^ num;  
	    }  
	}  
实现思路:我们先假设这两个数的值分别为ab。由上一个题目我们其实可以知道,遍历这个数组,把所有的数异或,得到的值应当是a^b,此时如果我们找到a^b中的一个一,就代表这两个值这个位上的值不同,即一个是0,另一个是1。这样我们就可以把数据分为两类,一类是这个位上是1的,另一类是这一位上是0的,分别把两类进行异或,就能得到两个数。
知识点:
	1.如何获得最右边的一个1
	temp = temp & (~temp + 1)
	2.如何分类
	num & temp = 0 代表这一位上是1的
	num & temp = 1 代表这一位上是0

五、插入排序

	public static void sort(int[] array){  
	    for (int i = 0; i < array.length; i++) {  
	        int temp = array[i];  
	        int j = i;  
	        for (; j > 0; j--) {  
	            if (array[j-1] > temp){  
	                array[j] = array[j-1];  
	            }else {  
	                break;  
	            }  
	        }  
	        array[j] = temp;  
	    }  
	}

1、实现思路

  1. 首先让 0 - 0 位置上有序,很明显,这个只有一个数据,因此本来就有序
  2. 再让 0 - 1 位置上有序,把 1 位置的数与 0 位置的数做比较,小的放在前面
  3. 再让 0 - 2 位置上有序,把 2 位置的数先与 1 位置数做比较,如果正序就不用管,如果逆序就交换并且再次与 0 位置的数做比较
  4. …………
  5. 其实就是取出有序区后面的那个数,插在有序区中合适的地方

2、插入排序 & 冒泡排序

其实不难发现,插入排序和冒泡排序都是比较相邻的数据,然后交换位置,都是原地排序算法,时间复杂度都为O(n²)。但实际上插入排序更受欢迎

  1. 插入排序的比较次数更少,因为冒泡排序移动过程中都是使用的没有排好序的数组,插入排序发现数据值的合适位置后就可以提前退出循环,而不用继续遍历下去
  2. 对于元素的交换,冒泡排序使用的是 swap ,需要三次赋值,而插入排序本质上是移动,将前面的元素向后移动一个位置就行

六、二分

1、二分查找

	public static int getNumIndex(int[] array, int num){  
	    int L = 0;  
	    int R = array.length - 1;  
	    while (L <= R){  
	        int middle = L + (R - L) / 2;  
	        if (array[middle] > num){  
	            R = middle - 1;  
	        }else if (array[middle] < num){  
	            L = middle + 1;  
	        }else {  
	            return middle;  
	        }  
	    }  
	    return -1;  
	}

这个就不多赘述了,最基础的二分查找

2、局部最小

	public static int getLocalMinimum(int[] array) {  
	    int left = 0, right = array.length - 1;  
	    if (array[left] < array[left + 1]) {  
	        return array[0];  
	    }  
	    if (array[right] < array[right - 1]) {  
	        return array[right - 1];  
	    }  
	    while (left <= right) {  
	        int middle = left + (right - left) / 2;  
	        if (array[middle] > array[middle + 1] && array[middle - 1] > array[middle]) {  
	            left = middle;  
	        } else if (array[middle] < array[middle + 1] && array[middle - 1] < array[middle]) {  
	            right = middle;  
	        } else if (array[middle] < array[middle + 1] && array[middle] < array[middle - 1]) {  
	            return array[middle];  
	        }  
	    }  
	    return-1;  
	}
题目:在一个无序数组中,相邻的数肯定不相等。求任意一个局部最小数。

实现思路:

  1. 首先判断数组的两侧是否有局部最小的数,即是否第一个数小于第二个数,或者最后一个数小于倒数第二个数,如果有,那就直接返回。
  2. 如果没有,那就代表左边的数处于一个下降的趋势,右边的数也处于一个下降的趋势
  3. 如果两边都是下降,那就代表必定存在一个波谷
  4. 这样我们只需要每次去看数据的趋势就好
    1. array[middle] > array[middle + 1] && array[middle - 1] > array[middle]
    2. 这种情况就是下降的趋势,二分为右边
    3. array[middle] < array[middle + 1] && array[middle - 1] < array[middle]
    4. 这种趋势就是上升的趋势,二分为右边

避坑:

起初在测试的时候,我在数组中有写相邻的数相等的情况,此时就会发现数组中存在局部最小的数,但是找不到的情况,我们可以手动调试一下

  • 对于数组 [10, 6, 6, 6, 7, 8, 9, 10, 11, 13, 14, 15, 51, 49, 50]
  • 我们第一次二分会发现中间的值是上升的趋势,因此我们从左边开始二分,从这里开始我们就漏掉了一个局部最小值 49
  • 然后在左边的二分中,我们能找到的只有两边都是 6 的 6,并不满足局部最小的条件(小于两边的数),因此最后就会出现右边的局部最小值 49 被漏掉,而且最后返回的结果为 -1,即找不到局部最小值的情况,这也是题目中相邻的数肯定不相等的意义所在