持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第一天,点击查看活动详情
最近在看左神的数据结构与算法,考虑到视频讲的内容和给出的资料的PDF有些出入,不方便去复习,打算出一个左神的数据结构与算法的笔记系列供大家复习,同时也可以加深自己对于这些知识的掌握,该系列以视频的集数为分隔,今天是第一篇:P3|认识复杂度与简单排序算法。
一、时间复杂度
时间复杂度需要讲的不多,就简单讲讲一些基本的规则
- 对于大O算法,我们在计算时间复杂度的时候,只需要取最高阶的就行。比如
An² + Bn + C,我们只需要取An²,其中常数项省略,即O(n²) - 对于相同的时间复杂度,不能直接比较,也不能比较常数项,比如
O(3N)和O(N),O(3N)不一定就没有O(N)快,因为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、异或运算基本规则
- 异或运算(^)的运算规则为:比较两个数的二进制数,相同为0,不同为1。
- 异或运算还可以理解为:无进位相加,即 1 + 1 = 0,但是不进位
- 按照上述的基本运算法则我们可以得出一些简单的结论:
- n ^ 0 = n (其中 n 为任意数)
- n ^ n = 0 (因为所有的二进制位相同,因此得到的为 0)
- 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);
- 这就是不通过中间变量去交换两个值的方法,为弄懂为什么可以这么写,我们不妨手动调试一下
- 假设
num1 = a num2 = b - 那么
num1 = num1 ^ num2 = a ^ b num2 = num1 ^ num2 = a ^ b ^ b = a ^ (b ^ b) = a ^ 0 = anum1 = num1 ^ num2 = a ^ b ^ a = b ^ (a ^ a) = b ^ 0 = b
- 假设
- 通过手动调试,我们发现,两个数确实通过三次异或运算交换了值,但是这种交换值的方法存在一些特殊的情况:
- 如果两个数相等,而且两个数的地址不同
- 如果
num1 = num2 = a (a ≠ 0) - 那么
num1 = num1 ^ num2 = 0 num2 = num1 ^ num2 = 0 ^ num2 = anum1 = num1 ^ num2 = 0 ^ num2 = a- 其实我们发现,还是能够交换的
- 如果
- 而如果两个数相等,而且两个数为同一个地址,此时就会出现都变成 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;
}
}
实现思路:我们先假设这两个数的值分别为a、b。由上一个题目我们其实可以知道,遍历这个数组,把所有的数异或,得到的值应当是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、实现思路
- 首先让 0 - 0 位置上有序,很明显,这个只有一个数据,因此本来就有序
- 再让 0 - 1 位置上有序,把 1 位置的数与 0 位置的数做比较,小的放在前面
- 再让 0 - 2 位置上有序,把 2 位置的数先与 1 位置数做比较,如果正序就不用管,如果逆序就交换并且再次与 0 位置的数做比较
- …………
- 其实就是取出有序区后面的那个数,插在有序区中合适的地方
2、插入排序 & 冒泡排序
其实不难发现,插入排序和冒泡排序都是比较相邻的数据,然后交换位置,都是原地排序算法,时间复杂度都为O(n²)。但实际上插入排序更受欢迎
- 插入排序的比较次数更少,因为冒泡排序移动过程中都是使用的没有排好序的数组,插入排序发现数据值的合适位置后就可以提前退出循环,而不用继续遍历下去
- 对于元素的交换,冒泡排序使用的是 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;
}
题目:在一个无序数组中,相邻的数肯定不相等。求任意一个局部最小数。
实现思路:
- 首先判断数组的两侧是否有局部最小的数,即是否第一个数小于第二个数,或者最后一个数小于倒数第二个数,如果有,那就直接返回。
- 如果没有,那就代表左边的数处于一个下降的趋势,右边的数也处于一个下降的趋势
- 如果两边都是下降,那就代表必定存在一个波谷
- 这样我们只需要每次去看数据的趋势就好
array[middle] > array[middle + 1] && array[middle - 1] > array[middle]- 这种情况就是下降的趋势,二分为右边
array[middle] < array[middle + 1] && array[middle - 1] < array[middle]- 这种趋势就是上升的趋势,二分为右边
避坑:
起初在测试的时候,我在数组中有写相邻的数相等的情况,此时就会发现数组中存在局部最小的数,但是找不到的情况,我们可以手动调试一下
- 对于数组
[10, 6, 6, 6, 7, 8, 9, 10, 11, 13, 14, 15, 51, 49, 50] - 我们第一次二分会发现中间的值是上升的趋势,因此我们从左边开始二分,从这里开始我们就漏掉了一个局部最小值 49
- 然后在左边的二分中,我们能找到的只有两边都是 6 的 6,并不满足局部最小的条件(小于两边的数),因此最后就会出现右边的局部最小值 49 被漏掉,而且最后返回的结果为 -1,即找不到局部最小值的情况,这也是题目中
相邻的数肯定不相等的意义所在