前言
这是 数据结构和算法面试题系列的下半部分,这部分主要是算法类 包括二分查找、排序算法、递归算法、随机算法、背包问题、数字问题等算法相关内容。本系列完整代码在 github 建了个仓库,所有代码都重新整理和做了一些基本的测试,代码仓库地址在这里: shishujuan/dsalg: 数据结构与算法系列汇总,如有错误,请在文章下面评论指出或者在 github 给我留言,我好及时改正以免误导其他朋友。
文章末尾有系列目录,可以按需取阅,如果需要测试,亦可以将仓库代码 clone 下来进行各种测试。如有错误或者引用不全、有侵权的地方,请大家给我指出,我好及时调整改正。如果本系列有帮助到你,也欢迎点赞或者在 github 上 star✨✨,十分感谢。
数据结构和算法面试题系列—二分查找算法详解
0.概述
二分查找本身是个简单的算法,但是正是因为其简单,更容易写错。甚至于在二分查找算法刚出现的时候,也是存在 bug 的(溢出的 bug),这个 bug 直到几十年后才修复(见《编程珠玑》)。本文打算对二分查找算法进行总结,并对由二分查找引申出来的问题进行分析和汇总。若有错误,请指正。本文完整代码在 这里 。
1.二分查找基础
相信大家都知道二分查找的基本算法,如下所示,这就是二分查找算法代码:
/**
* 基本二分查找算法
*/
int binarySearch(int a[], int n, int t)
{
int l = 0, u = n - 1;
while (l <= u) {
int m = l + (u - l) / 2; // 同(l+u)/ 2,这里是为了溢出
if (t > a[m])
l = m + 1;
else if (t < a[m])
u = m - 1;
else
return m;
}
return -(l+1);
}
复制代码
算法的思想就是:从数组中间开始,每次排除一半的数据,时间复杂度为 O(lgN)
。这依赖于数组有序这个性质。如果 t 存在数组中,则返回t在数组的位置;否则,不存在则返回 -(l+1)
。
这里需要解释下为什么 t 不存在数组中时不是返回 -1
而要返回 -(l+1)
。首先我们可以观察 l
的值,如果查找不成功,则 l
的值恰好是 t 应该在数组中插入的位置。
举个例子,假定有序数组 a={1, 3, 4, 7, 8}
, 那么如果t = 0
,则显然t不在数组中,则二分查找算法最终会使得l = 0 > u=-1
退出循环;如果 t = 9,则 t 也不在数组中,则最后 l = 5 > u = 4
退出循环。如果 t=5
,则最后l=3 > u=2
退出循环。因此在一些算法中,比如DHT(一致性哈希)中,就需要这个返回值来使得新加入的节点可以插入到合适的位置中,在求最长递增子序列的 NlgN 算法中,也用到了这一点,参见博文最长递增子序列算法。
还有一个小点就是之所以返回 -(l+1)
而不是直接返回 -l
是因为 l
可能为 0,如果直接返回 -l
就无法判断是正常返回位置 0 还是查找不成功返回的 0。
2.查找有序数组中数字第一次出现位置
现在考虑一个稍微复杂点的问题,如果有序数组中有重复数字,比如数组 a={1, 2, 3, 3, 5, 7, 8}
,需要在其中找出 3 第一次出现的位置。这里3第一次出现位置为 2。这个问题在《编程珠玑》第九章有很好的分析,这里就直接用了。算法的精髓在于循环不变式的巧妙设计,代码如下:
/**
* 二分查找第一次出现位置
*/
int binarySearchFirst(int a[], int n, int t)
{
int l = -1, u = n;
while (l + 1 != u) {
/*循环不变式a[l]<t<=a[u] && l<u*/
int m = l + (u - l) / 2; //同(l+u)/ 2
if (t > a[m])
l = m;
else
u = m;
}
/*assert: l+1=u && a[l]<t<=a[u]*/
int p = u;
if (p>=n || a[p]!=t)
p = -1;
return p;
}
复制代码
算法分析:设定两个不存在的元素 a[-1]和 a[n],使得 a[-1] < t <= a[n]
,但是我们并不会去访问者两个元素,因为(l+u)/2 > l=-1
, (l+u)/2 < u=n
。循环不变式为l<u && t>a[l] && t<=a[u]
。循环退出时必然有 l+1=u
, 而且 a[l] < t <= a[u]
。循环退出后u的值为t可能出现的位置,其范围为[0, n]
,如果 t 在数组中,则第一个出现的位置 p=u
,如果不在,则设置 p=-1
返回。该算法的效率虽然解决了更为复杂的问题,但是其效率比初始版本的二分查找还要高,因为它在每次循环中只需要比较一次,前一程序则通常需要比较两次。
举个例子:对于数组 a={1, 2, 3, 3, 5, 7, 8}
,我们如果查找 t=3
,则可以得到 p=u=2
,如果查找 t=4,a[3]<t<=a[4]
, 所以p=u=4
,判断 a[4] != t
,所以设置p=-1
。 一种例外情况是 u>=n
, 比如t=9
,则 u=7
,此时也是设置 p=-1
.特别注意的是,l=-1,u=n
这两个值不能写成l=0,u=n-1
。虽然这两个值不会访问到,但是如果改成后面的那样,就会导致二分查找失败,那样就访问不到第一个数字。如在 a={1,2,3,4,5}
中查找 1,如果初始设置 l=0,u=n-1
,则会导致查找失败。
扩展 如果要查找数字在数组中最后出现的位置呢?其实这跟上述算法是类似的,稍微改一下上面的算法就可以了,代码如下:
/**
* 二分查找最后一次出现位置
*/
int binarySearchLast(int a[], int n, int t)
{
int l = -1, u = n;
while (l + 1 != u) {
/*循环不变式, a[l] <= t < a[u]*/
int m = l + (u - l) / 2;
if (t >= a[m])
l = m;
else
u = m;
}
/*assert: l+1 = u && a[l] <= t < a[u]*/
int p = l;
if (p<=-1 || a[p]!=t)
p = -1;
return p;
}
复制代码
当然还有一种方法可以将查询数字第一次出现和最后一次出现的代码写在一个程序中,只需要对原始的二分查找稍微修改即可,代码如下:
/**
* 二分查找第一次和最后一次出现位置
*/
int binarySearchFirstAndLast(int a[], int n, int t, int firstFlag)
{
int l = 0;
int u = n - 1;
while(l <= u) {
int m = l + (u - l) / 2;
if(a[m] == t) { //找到了,判断是第一次出现还是最后一次出现
if(firstFlag) { //查询第一次出现的位置
if(m != 0 && a[m-1] != t)
return m;
else if(m == 0)
return 0;
else
u = m - 1;
} else { //查询最后一次出现的位置
if(m != n-1 && a[m+1] != t)
return m;
else if(m == n-1)
return n-1;
else
l = m + 1;
}
}
else if(a[m] < t)
l = m + 1;
else
u = m - 1;
}
return -1;
}
复制代码
3.旋转数组元素查找问题
题目
把一个有序数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。例如数组{3, 4, 5, 1, 2}为{1, 2, 3, 4, 5}的一个旋转。现在给出旋转后的数组和一个数,旋转了多少位不知道,要求给出一个算法,算出给出的数在该数组中的下标,如果没有找到这个数,则返回 -1。要求查找次数不能超过 n。
分析
由题目可以知道,旋转后的数组虽然整体无序了,但是其前后两部分是部分有序的。由此还是可以使用二分查找来解决该问题的。
解1:两次二分查找
首先确定数组分割点,也就是说分割点两边的数组都有序。比如例子中的数组以位置2分割,前面部分{3,4,5}有序,后半部分{1,2}有序。然后对这两部分分别使用二分查找即可。代码如下:
/**
* 旋转数组查找-两次二分查找
*/
int binarySearchRotateTwice(int a[], int n, int t)
{
int p = findRotatePosition(a, n); //找到旋转位置
if (p == -1)
return binarySearchFirst(a, n, t); //如果原数组有序,则直接二分查找即可
int left = binarySearchFirst(a, p+1, t); //查找左半部分
if (left != -1)
return left; //左半部分找到,则直接返回
int right = binarySearchFirst(a+p+1, n-p-1, t); //左半部分没有找到,则查找右半部分
if (right == -1)
return -1;
return right+p+1; //返回位置,注意要加上p+1
}
/**
* 查找旋转位置
*/
int findRotatePosition(int a[], int n)
{
int i;
for (i = 0; i < n-1; i++) {
if (a[i+1] < a[i])
return i;
}
return -1;
}
复制代码
解2:一次二分查找
二分查找算法有两个关键点:1)数组有序;2)根据当前区间的中间元素与t的大小关系,确定下次二分查找在前半段区间还是后半段区间进行。
仔细分析该问题,可以发现,每次根据 l
和 u
求出 m
后,m
左边([l, m]
)和右边([m, u]
)至少一个是有序的。a[m]分别与a[l]和a[u]比较,确定哪一段是有序的。
- 如果左边是有序的,若
t<a[m] && t>a[l]
, 则u=m-1
;其他情况,l =m+1
; - 如果右边是有序的,若
t> a[m] && t<a[u]
则l=m+1
;其他情况,u =m-1
; 代码如下:
/**
* 旋转数组二分查找-一次二分查找
*/
int binarySearchRotateOnce(int a[], int n, int t)
{
int l = 0, u = n-1;
while (l <= u) {
int m = l + (u-l) / 2;
if (t == a[m])
return m;
if (a[m] >= a[l]) { //数组左半有序
if (t >= a[l] && t < a[m])
u = m - 1;
else
l = m + 1;
} else { //数组右半段有序
if (t > a[m] && t <= a[u])
l = m + 1;
else
u = m - 1;
}
}
return -1;
}
复制代码
数据结构和算法面试题系列—排序算法之基础排序
0.概述
排序算法也是面试中常常提及的内容,问的最多的应该是快速排序、堆排序。这些排序算法很基础,但是如果平时不怎么写代码的话,面试的时候总会出现各种 bug。虽然思想都知道,但是就是写不出来。本文打算对各种排序算法进行一个汇总,包括插入排序、冒泡排序、选择排序、计数排序、归并排序,基数排序、桶排序、快速排序等。快速排序比较重要,会单独写一篇,而堆排序见本系列的二叉堆那篇文章即可。
需要提到的一点就是:插入排序,冒泡排序,归并排序,计数排序都是稳定的排序,而其他排序则是不稳定的。本文完整代码在 这里。
1.插入排序
插入排序是很基本的排序,特别是在数据基本有序的情况下,插入排序的性能很高,最好情况可以达到O(N)
,其最坏情况和平均情况时间复杂度都是 O(N^2)
。代码如下:
/**
* 插入排序
*/
void insertSort(int a[], int n)
{
int i, j;
for (i = 1; i < n; i++) {
/*
* 循环不变式:a[0...i-1]有序。每次迭代开始前,a[0...i-1]有序,
* 循环结束后i=n,a[0...n-1]有序
* */
int key = a[i];
for (j = i; j > 0 && a[j-1] > key; j--) {
a[j] = a[j-1];
}
a[j] = key;
}
}
复制代码
2.希尔排序
希尔排序内部调用插入排序来实现,通过对 N/2,N/4...1
阶分别排序,最后得到整体的有序。
/**
* 希尔排序
*/
void shellSort(int a[], int n)
{
int gap;
for (gap = n/2; gap > 0; gap /= 2) {
int i;
for (i = gap; i < n; i++) {
int key = a[i], j;
for (j = i; j >= gap && key < a[j-gap]; j -= gap) {
a[j] = a[j-gap];
}
a[j] = key;
}
}
}
复制代码
3.选择排序
选择排序的思想就是第 i 次选取第 i 小的元素放在位置 i。比如第 1 次就选择最小的元素放在位置 0,第 2 次选择第二小的元素放在位置 1。选择排序最好和最坏时间复杂度都为 O(N^2)
。代码如下:
/**
* 选择排序
*/
void selectSort(int a[], int n)
{
int i, j, min, tmp;
for (i = 0; i < n-1; i++) {
min = i;
for (j = i+1; j < n; j++) {
if (a[j] < a[min])
min = j;
}
if (min != i)
tmp = a[i], a[i] = a[min], a[min] = tmp; //交换a[i]和a[min]
}
}
复制代码
循环不变式:在外层循环执行前,a[0...i-1]
包含 a
中最小的 i
个数,且有序。
-
初始时,
i=0
,a[0...-1]
为空,显然成立。 -
每次执行完成后,
a[0...i]
包含a
中最小的i+1
个数,且有序。即第一次执行完成后,a[0...0]
包含a
最小的1
个数,且有序。 -
循环结束后,
i=n-1
,则a[0...n-2]
包含a
最小的n-1
个数,且已经有序。所以整个数组有序。
4.冒泡排序
冒泡排序时间复杂度跟选择排序相同。其思想就是进行 n-1
趟排序,每次都是把最小的数上浮,像鱼冒泡一样。最坏情况为 O(N^2)
。代码如下:
/**
* 冒泡排序-经典版
*/
void bubbleSort(int a[], int n)
{
int i, j, tmp;
for (i = 0; i < n; i++) {
for (j = n-1; j >= i+1; j--) {
if (a[j] < a[j-1])
tmp = a[j], a[j] = a[j-1], a[j-1] = tmp;
}
}
}
复制代码
循环不变式:在循环开始迭代前,子数组 a[0...i-1]
包含了数组 a[0..n-1]
的 i-1
个最小值,且是排好序的。
对冒泡排序的一个改进就是在每趟排序时判断是否发生交换,如果一次交换都没有发生,则数组已经有序,可以不用继续剩下的趟数直接退出。改进后代码如下:
/**
* 冒泡排序-优化版
*/
void betterBubbleSort(int a[], int n)
{
int tmp, i, j;
for (i = 0; i < n; i++) {
int sorted = 1;
for (j = n-1; j >= i+1; j--) {
if (a[j] < a[j-1]) {
tmp = a[j], a[j] = a[j-1], a[j-1] = tmp;
sorted = 0;
}
}
if (sorted)
return ;
}
}
复制代码
5.计数排序
假定数组为 a[0...n-1]
,数组中存在重复数字,数组中最大数字为k,建立两个辅助数组 b[]
和 c[]
,b[]
用于存储排序后的结果,c[]
用于存储临时值。时间复杂度为 O(N)
,适用于数字范围较小的数组。
计数排序原理如上图所示,代码如下:
/**
* 计数排序
*/
void countingSort(int a[], int n)
{
int i, j;
int *b = (int *)malloc(sizeof(int) * n);
int k = maxOfIntArray(a, n); // 求数组最大元素
int *c = (int *)malloc(sizeof(int) * (k+1)); //辅助数组
for (i = 0; i <= k; i++)
c[i] = 0;
for (j = 0; j < n; j++)
c[a[j]] = c[a[j]] + 1; //c[i]包含等于i的元素个数
for (i = 1; i <= k; i++)
c[i] = c[i] + c[i-1]; //c[i]包含小于等于i的元素个数
for (j = n-1; j >= 0; j--) { // 赋值语句
b[c[a[j]]-1] = a[j]; //结果存在b[0...n-1]中
c[a[j]] = c[a[j]] - 1;
}
/*方便测试代码,这一步赋值不是必须的*/
for (i = 0; i < n; i++) {
a[i] = b[i];
}
free(b);
free(c);
}
复制代码
扩展: 如果代码中的给数组 b[]
赋值语句 for (j=n-1; j>=0; j--)
改为 for(j=0; j<=n-1; j++)
,该代码仍然正确,只是排序不再稳定。
6.归并排序
归并排序通过分治算法,先排序好两个子数组,然后将两个子数组归并。时间复杂度为 O(NlgN)
。代码如下:
/*
* 归并排序-递归
* */
void mergeSort(int a[], int l, int u)
{
if (l < u) {
int m = l + (u-l)/2;
mergeSort(a, l, m);
mergeSort(a, m + 1, u);
merge(a, l, m, u);
}
}
/**
* 归并排序合并函数
*/
void merge(int a[], in