代码随想录算法训练营Day1 | 数组理论基础 704. 二分查找 27. 移除元素

136 阅读13分钟

数组理论基础

数组相对而言是比较基础的数据类型,对应数据结构中的线性表,虽然结构简单,但是实际可解决的问题颇多。C语言中的数组可以有助于理解相关的内存结构。

声明一个数组,首先打印一下数组地址:

int arr1[] = {0,1,2,3,4,5,6};                                       //声明一个数组
printf("array1 pointer:%p\n", (void *)arr1);              //数组名转为void*指针,确保代码的类型安全、可移植性和避免编译器警告。

可以得到结果:

array1 pointer:0x16fdfeaa0

接下来打印一下表中其他元素

printf("array1[0] pointer:%p\n", (void *)&arr1[0]);                 //取第一个数据的地址
printf("array1[1] pointer:%p\n", (void *)&arr1[1]);                 //取第二个数据的地址
printf("array1[2] pointer:%p\n", (void *)&arr1[2]);                 //取第二个数据的地址

打印结果:

array1 pointer:0x16fdfeaa0
array1[0] pointer:0x16fdfeaa0
array1[1] pointer:0x16fdfeaa4
array1[2] pointer:0x16fdfeaa8

我们可以发现,数组地址其实和数组第一个元素内存地址相同,其实就是同一个位置,同时每个元素地址之间相差4个单位,实际上表示的就是int在此计算机中的长度4个字节

我们查看一下内存中的内容,发现数组的实际组成和我们猜想的一致:

image-20231226232039394

如果是二维数组,那又会是什么样呢,我们继续尝试:

int arr2[3][4] = {
  {10, 11, 12, 13} ,                                                     //初始化索引号为 0 的行
  {14, 15, 16, 17} ,                                                     //初始化索引号为 1 的行
  {18, 19, 20, 21}                                                     //初始化索引号为 2 的行
};

printf("array2 pointer:%p\n", (void *)arr2);              //数组名转为void*指针,确保代码的类型安全、可移植性和避免编译器警告。
printf("array2[0] pointer:%p\n", (void *)&arr2[0][0]);              //取第一行的地址
printf("array2[0][0] pointer:%p\n", (void *)&arr2[0][0]);           //取第一行第一个元素的地址
printf("array2[1][3] pointer:%p\n", (void *)&arr2[1][3]);           //取第二行最后一个元素的地址
printf("array2[2][0] pointer:%p\n", (void *)&arr2[2][0]);           //取第三行第一个数据的地址

输出结果为:

array2 pointer:0x16fdfea70
array2[0] pointer:0x16fdfea70
array2[0][0] pointer:0x16fdfea70
array2[1][3] pointer:0x16fdfea8c
array2[2][0] pointer:0x16fdfea90

我们可以发现,二维数组地址、二维数组首行地址以及二维数组首行首列地址依旧完全一致,也就是说,每一行其实也可以看做一个子数组。

比较第二列最后一个元素和第三列第一个元素的位置,0x16fdfea8c和0x16fdfea90之间还是只相差4,说明虽然是二维数组但是实际上在内存中其实还是存储在一片连续的空间中,本质上其实还是可以当做一个一维数组:

image

704.二分查找

基本思想不必概述,主要还是利用有序无重复序列的特性,减少了无谓比较次数。假设为递增序列。

每次选择中间位置的元素,

  • 如果目标关键字等于中间位置值,说明目标就是中间位置的元素,结束查找;

  • 如果目标关键字小于中间位置值,说明目标只可能在小于中间位置的那一侧找到,将这一侧的子表当做查找范围重新开始查找;

  • 如果目标关键字大于中间位置值,说明目标只可能在大于中间位置的那一侧找到,将这一侧的子表当做查找范围重新开始查找。

根据左指针和右指针的实际含义的不同对应的区间规则不同,我们可以将二分法分成两种写法,左闭右开左闭右闭 。两种方式的不同主要在于右指针的实际含义,从而会间接影响下一轮的区间选取以及循环结束的条件应该怎么选取。

左闭右闭

左闭右闭,顾名思义就是左右两个指针定义的访问区间是一个闭区间,也就是说,left和right都实际指向一个可以访问的、有意义的位置,因此如果是左闭右闭的方式,left指向区间最左元素right指向最右元素

image-20231227003404520

初始状态

假设要处理的数组长度为n,第一轮查找前,left应该指向第一个元素,因此left取0,而right则应该指向最右边的可访问元素的位置,因此应该取n-1.

int left = 0;                                   //left最初指向第一个元素
int right = numsSize - 1;                       //right最初指向最后一个元素

区间选取

如果当前未到达结束条件,而中间元素和目标不匹配,那么就要去更小的局部区间继续查找。

  1. 假设如果目标关键字大于中间位置值,说明目标只可能在大于中间位置的那一侧找到,right不用变,left这一侧要取闭(区间要包含所指向元素),中间元素已经确定不等于目标元素了,因此left直接取mid+1,然后继续查找;
  2. 如果目标关键字小于中间位置值,说明目标只可能在小于中间位置的那一侧找到,left不用变,right这一侧要取闭区间要包含所指向元素),中间元素已经确定不等于目标元素了,因此right直接取mid-1,然后继续查找;
mid = (left + right) / 2;
int comparator = nums[mid];
if (target < comparator){                   //目标小于中间元素,修改right值为mid-1
    right = mid - 1;
}else if(target > comparator) {             //目标大于中间元素,修改left值为mid+1
    left = mid + 1;
}else{                                      //目标等于中间元素,查找成功
    return mid;
}

结束条件

当查找到只剩最后一个元素时,表示已经来到了最后一次比较。因为之前说过left和right都实际指向一个可以访问的、有意义的位置,因此此时他们会相遇,共同指向同一个元素的位置,因此left和right相等时是有意义的,表示它们指向了同一个元素,也就是本轮比较是最后一轮比较了:

image-20231227005541000

本次循环开始时:

  • left == right,(1)
  • mid == (left + right) / 2 == left == right.(2)

结束后,就可以得出查找结果,不需要再继续比较。查找成功会在循环中返回,因此结束循环时必定是查找失败的情况,分析一下失败的情况:

  1. 如果目标小于中间元素,则left取mid+1,由(1)(2)可得,结束后left == right + 1
  2. 如果目标小=大于中间元素,则right取mid-1,由(1)(2)可得,结束后right == left - 1

显然,最后的循环结束条件都可以总结为right < left,当left <= right是都可以进行比较。

while (left <= right) {                         //结束条件为left超越right,说明此前两者已经指向了同一个元素
    mid = (left + right) / 2;
    int comparator = nums[mid];
    /*比较流程*/
}
return -1;                                      //跳出循环后仍未找到,说明元素不在表中   

最终方案

/// 折半查找(左闭右闭)
/// - Parameters:
///   - nums: 待处理的数组
///   - numsSize: 数组长度
///   - target: 查找目标
int search(int* nums, int numsSize, int target) {
    int left = 0;                                   //left最初指向第一个元素
    int right = numsSize - 1;                       //right最初指向最后一个元素
    int mid;
    while (left <= right) {                         //结束条件为left超越right,说明此前两者已经指向了同一个元素
        mid = (left + right) / 2;
        int comparator = nums[mid];
        if (target < comparator){                   //目标小于中间元素,修改right值为mid-1
            right = mid - 1;
        }else if(target > comparator) {             //目标大于中间元素,修改left值为mid+1
            left = mid + 1;
        }else{                                      //目标等于中间元素,查找成功
            return mid;
        }
    }
    return -1;                                      //跳出循环后仍未找到,说明元素不在表中
}

image-20231227014518504

左闭右开

左闭右闭开,顾名思义就是左右两个指针定义的访问区间是一个左闭右开的区间,left指向一个可以访问的、有意义的位置,right则不一样,由于是开区间,right指向的位置应该不在可访问区间内。因此left依旧指向区间最左元素,right指向的位置应该是最右元素再靠右的那个位置(也就是最右可访问位置后一位的位置):

image-20231227015330411

初始状态

假设要处理的数组长度为n,第一轮查找前,left应该指向第一个元素,因此left取0,而right则应该指向最右边的可访问元素的位置的后一位,因此应该取n.

int left = 0;                                   //left最初指向第一个元素
int right = numsSize;                           //right最初指向最后一个元素后一位

区间选取

如果当前未到达结束条件,而中间元素和目标不匹配,那么就要去更小的局部区间继续查找。

  1. 假设如果目标关键字大于中间位置值,说明目标只可能在大于中间位置的那一侧找到,right不用变,left这一侧要取闭(区间要包含所指向元素),中间元素已经确定不等于目标元素了,因此left直接取mid+1,然后继续查找;
  2. 如果目标关键字小于中间位置值,说明目标只可能在小于中间位置的那一侧找到,left不用变,right这一侧要取开区间,接下来的查找区间不需要包含所指向元素),中间元素已经确定不等于目标元素了,因此right可以直接取mid,然后继续查找;
mid = (left + right) / 2;
int comparator = nums[mid];
if (target < comparator){                   //目标小于中间元素,修改right值为mid
    right = mid;
}else if(target > comparator) {             //目标大于中间元素,修改left值为mid+1
    left = mid + 1;
}else{                                      //目标等于中间元素,查找成功
    return mid;
}

结束条件

当查找到只剩最后一个元素时,表示已经来到了最后一次比较。left依旧指向一个可以访问的、有意义的位置,而right则是指向最右可访问位置再靠右的那个位置(也就是最右可访问位置后一位的位置),因此可知:

  1. 如果left == right - 1(即left和right相邻),测试待访问区间就只剩一个元素;
  2. 如果left == right,则表示已经结束了所有的比较。

image-20231227020530711

因此,最后的循环结束条件都可以总结为right = left,当left < right时都可以进行比较。

while (left < right) {                         //结束条件为left等于right,说明此时已经比较了所有元素
    mid = (left + right) / 2;
    int comparator = nums[mid];
    /*比较流程*/
}
return -1;                                      //跳出循环后仍未找到,说明元素不在表中   

最终方案

/// 折半查找(左闭右开)
/// - Parameters:
///   - nums: 待处理的数组
///   - numsSize: 数组长度
///   - target: 查找目标
int search(int* nums, int numsSize, int target) {
    int left = 0;                                   //left最初指向第一个元素
    int right = numsSize;                           //right最初指向最后一个元素后一位
    int mid;
    while (left < right) {                         //结束条件为left等于right,left等于right-1时已经查找到最后一轮了
        mid = (left + right) / 2;
        int comparator = nums[mid];
        if (target < comparator){                   //目标小于中间元素,修改right值为mid
            right = mid;
        }else if(target > comparator) {             //目标大于中间元素,修改left值为mid+1
            left = mid + 1;
        }else{                                      //目标等于中间元素,查找成功
            return mid;
        }
    }
    return -1;                                      //跳出循环后仍未找到,说明元素不在表中
}

image-20231227020759531

27.移除元素

解决思路

假设待处理数组为[0,1,2,2,3,0,4,2],待删除元素val为2:

统计移动法

image-20231227025312067

假设我们关注列表中的待移除元素数量(记为valCount),我们可以观察发现:

  • 对比移除前后的元素位置,相当于把原数组的待移除元素移除后,剩余元素尽可能向前移动填满移除元素后留下的空缺。
  • 保留元素向前移动的距离和在该元素前面的待移除元素数量有直接关系。假如某个元素前方有valCount个元素要被移除,显然它就应该向前移动valCount个单位的距离。

按照这个思路,我们可以这样通过一次遍历实现元素移除:

  1. 从头开始遍历列表,并开始统计待移除元素数量(记为valCount)。
  2. 如果当前访问的元素是待移除元素,则让valCount加一;
  3. 如果当前访问的元素是保留元素,则让这个元素向前移动valCount个单位;
  4. 结束遍历后,新数组长度为原数组长度length-valCount;

按照这个思路,可以编写如下代码:

/// 移除元素
/// - Parameters:
///   - nums: 待处理数组
///   - numsSize: 数组长度
///   - val: 待移除元素
int removeElement(int* nums, int numsSize, int val) {
    int valCount = 0;                                   //待移除元素统计数量
    for (int i = 0; i < numsSize; i++) {
        if(nums[i] == val){                             //遇到待移除元素,统计数量加一
            valCount++;
        }else{                                          //遇到非待移除元素,向前移动待移除元素数量个单位
            nums[i - valCount] = nums[i];
        }
    }
    return numsSize - valCount;                         //新数组长度为原长度减去待移除元素统计数量
}

image-20231227022858520

快慢指针法

我们可以换一个思路,假如我们只关注保留元素,我们可以直接统计保留元素的数量,并且每探测到一个保留元素就让它移动到最终应该移动到的相应的位置,同样可以实现功能:

image-20231227090735934

如图所示,用一个变量position记录当前已统计到的保留元素数量,同时也可以作为目前访问到的保留元素的最终位置,用i表示访问当前元素的探测指针,则有:

  1. position初始值应为0,要从第一个元素开始,i的初始值也为0;
  2. 如果当前访问到的元素是保留元素,则把这个元素移动到当前position指向的位置,移动结束后position++,表示统计数量加一,也表示移动到下一个需要填入的位置
  3. 如果当前访问到的元素是待移除元素,则探测指针直接跳过这个元素position也不做任何变动,因为目前访问到的是待移除的元素,最后不会移动到新数组中。
  4. 访问完所有元素后,position就指向最后一个保留元素的最终位置的后一位,也表示新数组的长度

显然i会跳过一些元素,会比position移动得快,因此也可以叫做快慢指针法。

按照这个思路,可以编写如下代码:

/// 移除元素(快慢指针)
/// - Parameters:
///   - nums: 待处理数组
///   - numsSize: 数组长度
///   - val: 待移除元素
int removeElement(int* nums, int numsSize, int val) {
    int position = 0;                                   //保留元素统计数量
    for (int i = 0; i < numsSize; i++) {
        if(nums[i] != val){                             //遇到保留元素,移动该元素到当前统计为止,移动后统计数量加一
            nums[position] = nums[i];
            position++;
        }
    }
    return position;                                    //最终保留元素统计数量就是新数组长度
}

image-20231227095419041