将最小可能的正整数插入一个唯一整数数组中

9
我正在尝试解决这个面试题:给定一个由唯一正整数组成的数组,找到最小的可插入数字,使得每个整数仍然是唯一的。算法应在O(n)时间复杂度内完成,并且额外空间复杂度应为常数。可以将数组中的值分配给其他整数。
例如,对于数组[5, 3, 2, 7],输出应为1。但是,对于[5, 3, 2, 7, 1],答案应该是4。
我的第一个想法是对数组进行排序,然后再次遍历数组以查找连续序列断裂的位置,但排序需要超过O(n)的时间复杂度。
欢迎提出任何想法!

1
不是,但找到最大值应该是O(n)的,需要常量空间复杂度。 - maranic
1
@SurakofVulcan 唯一的规则是数组中的值应保持为正整数。 - maranic
1
从问题描述中可以得知:“允许将数组中的值分配给其他整数。” 这是O(n)空间,而不是常数。 - גלעד ברקן
1
@גלעדברקן 这只是替换数组,对吗? - Yassin Hajaj
2
@גלעדברקן:不,输入数据结构的空间不计算。 - user1196549
显示剩余10条评论
7个回答

5

数组 A 的索引从1开始。我们称一个值为活跃的值,当它不为零且不超过 n 时。

  • 扫描数组直到找到一个活跃值,令 A[i] = k (如果找不到,则停止);

  • A[k] 是活跃的时,

    • A[k] 移动到 k 的位置,并清除 A[k]
  • i 继续扫描数组,直到到达数组末尾。

这样一遍扫描后,数组中与某些整数对应的所有元素都被清除了。

  • 找到第一个非零元素,并报告它的索引。

例如:

[5, 3, 2, 7], clear A[3]
[5, 3, 0, 7], clear A[2]
[5, 0, 0, 7], done

答案是 1
例如:
[5, 3, 2, 7, 1], clear A[5],
[5, 3, 2, 7, 0], clear A[1]
[0, 3, 2, 7, 0], clear A[3],
[0, 3, 0, 7, 0], clear A[2],
[0, 0, 0, 7, 0], done

答案是4

第一遍循环是线性的,因为每个数字只被查看一次(并立即清除),而i会逐渐增加。

第二遍循环是线性搜索。


A= [5, 3, 2, 7, 1]
N= len(A)

print(A)
for i in range(N):
    k= A[i]
    while k > 0 and k <= N:
        A[k-1], k = 0, A[k-1] # -1 for 0-based indexing
        print(A)

[5, 3, 2, 7, 1]
[5, 3, 2, 7, 0]
[0, 3, 2, 7, 0]
[0, 3, 2, 7, 0]
[0, 3, 0, 7, 0]
[0, 0, 0, 7, 0]
[0, 0, 0, 7, 0]

更新:

根据גלעד ברקן的想法,我们可以以不破坏值的方式标记数组元素。然后报告第一个未标记的索引。

print(A)
for a in A:
    a= abs(a)
    if a <= N:
        A[a-1]= - A[a-1] # -1 for 0-based indexing
    print(A)

[5, 3, 2, 7, 1]
[5, 3, 2, 7, -1]
[5, 3, -2, 7, -1]
[5, -3, -2, 7, -1]
[5, -3, -2, 7, -1]
[-5, -3, -2, 7, -1]

1
我的答案执行了相同的想法,即标记已查看的值,只是避免使用额外的指针。 - גלעד ברקן
1
@YassinHajaj:正如我的解决方案所正确报告的那样,答案是1。 - user1196549
1
我不明白为什么会有负评,这个代码很好且时间复杂度为O(n)。 - aka.nice
@aka.nice:没错。仔细想想,最好像גלעד ברקן一样非破坏性地标记数组元素,玩弄符号,这样你只需要使用两个前向循环即可。 - user1196549
我同意,这是一个非常好的解决方案。你的解决方案更接近我的(使用交换的想法)。 - aka.nice
显示剩余2条评论

4

根据问题描述:“允许将数组中的值赋给其他整数。” 这是O(n)空间,而不是常量。

循环遍历数组,并将对于|A[i]| < 数组长度的元素,将其对应的A[ |A[i]| - 1 ]乘以-1。第二次循环并输出第一个未标记为负数的单元格的索引+1,如果它们全部被标记,则输出(数组长度+1)。这利用了事实上数组中不能有超过(数组长度)个不同的整数。


因为输入空间不计算在内。比较像快速选择这样的东西。如果您可以将其视为O(n)空间,则任何实际的辅助O(n)空间都不会改变空间复杂度... - Ry-
@Ry 但是我们正在重复使用分配给输入的空间来解决问题。这意味着解决方案实现正在使用O(n)空间。 - גלעד ברקן
1
非常好。也许可以表明您假定从0开始索引。 - Damien

1
我会使用以1为基础的索引。
思路是重复使用输入集合,并安排在第i个位置交换整数i,如果它的当前位置大于i。这可以在O(n)时间内完成。
然后在第二次迭代中,您可以找到第一个不包含i的索引i,这也是O(n)。
在Smalltalk中,实现为Array(self是数组):
firstMissing
    self size to: 1 by: -1 do: [:i |
        [(self at: i) < i] whileTrue: [self swap: i with: (self at: i)]].
    1 to: self size do: [:i |
        (self at: i) = i ifFalse: [^i]].
    ^self size + 1

所以我们有两个O(n)的循环,但是第一个循环内部还有另一个循环(whileTrue:)。那么第一个循环真的是O(n)吗?

是的,因为每个元素最多只会交换一次,因为它们将到达它们正确的位置。我们可以看到交换的累积数量受到数组大小的限制,第一个循环的总成本最多为2*n,包括最后一个搜索的总成本最多为3*n,仍然是O(n)。

您还可以看到,我们不关心 (self at: i) > i and: [(self at:i) <= self size] 的情况下进行交换,为什么?因为我们确信在这种情况下会有一个更小的缺失元素。

一个小的测试案例:

| trial |
trial := (1 to: 100100) asArray shuffled first: 100000.
self assert: trial copy firstMissing = trial sorted firstMissing.

只有当输入的大小至少与数组中的最大值一样大时,此方法才有效。 - fjardon
@fjardon 不对。我只在 (self at: i) < i 时才交换索引 iself at: i 的位置。因此,我从未超出 self size(在 Smalltalk 中将会导致异常的缓冲区溢出)。 - aka.nice

0

使用这个简单而有效的算法:

A is [5, 3, 2, 7]
1- Define B With Length = A.Length;                            (O(1))
2- initialize B Cells With 1;                                  (O(n))
3- For Each Item In A:
        if (B.Length <= item) then B[Item] = -1                (O(n))
4- The answer is smallest index in B such that B[index] != -1  (O(n))

1
这个解决方案有什么问题?! - Hamed

0

我差不多自己找到了正确的方法,但还是不得不搜索一下,最后在这里找到了:https://www.geeksforgeeks.org/find-the-smallest-positive-number-missing-from-an-unsorted-array/

注意:此方法会破坏原始数据

原问题中没有说明不能破坏数据。

现在我将解释您需要做什么。

基本的“aha”是,第一个缺失的数字必须出现在前N个正数中,其中N是数组的长度。

一旦您理解了这一点,并意识到可以使用数组本身的值作为标记,您只需要解决一个问题:数组中是否有小于1的数字?如果有,我们需要处理它们。

处理0或负数可以在O(n)时间内完成。获取两个整数,一个用于当前值,另一个用于数组的末尾。当我们扫描时,如果发现0或负数,我们使用第三个整数与数组中的最终值进行交换。然后我们递减数组指针的末尾。我们继续执行,直到当前指针超过数组指针的末尾。
代码示例:
while (list[end] < 1) {
   end--;
}
while (cur< end) {
   if (n < 1) {
      swap(list[cur], list[end]);
      while (list[end] < 1) {
         end--;
      }
   }
}

现在我们有了数组的末尾和截断后的数组。从这里开始,我们需要看看如何使用数组本身。由于我们关心的所有数字都是正数,并且我们有一个指向它们数量位置的指针,如果数组中有一个数字,我们可以简单地将其乘以-1来标记该位置为存在。
例如:[5, 3, 2, 7, 1] 当我们读取到3时,我们将其更改为[5, 3, -2, 7, 1]
代码示例:
for (cur = 0; cur <= end; begin++) {
   if (!(abs(list[cur]) > end)) {
      list[abs(list[cur]) - 1] *= -1;
   }
}

现在,请注意:您需要读取位置中整数的绝对值,因为它可能会变成负数。还要注意,如果一个整数大于您的列表结束指针,则不要更改任何内容,因为该整数将无关紧要。

最后,一旦您读取了所有正值,请遍历它们以找到当前为正的第一个数字。这个位置代表您的第一个缺失的数字。

Step 1: Segregate 0 and negative numbers from your list to the right. O(n)
Step 2: Using the end of list pointer iterate through the entire list marking
        relevant positions negative. O(n-k)
Step 3: Scan the numbers for the position of the first non-negative number. O(n-k)
Space Complexity: The original list is not counted, I used 3 integers beyond that. So
        it is O(1)

需要提到的一件事是,列表 [5, 4, 2, 1, 3] 最终会变成 [-5, -4, -2, -1, -3],因此在这种情况下,您应该选择列表结束位置后的第一个数字,即6作为结果。

步骤3的代码示例:

for (cur = 0; cur < end; cur++) {
   if (list[cur] > 0) {
      break;
   }
}
print(cur);

0

你可以按照以下步骤进行操作。

  • 找到最大值(m),所有元素的和(s),以及元素的数量(n)
  • 有m-n个元素缺失,它们的总和为q=sum(1..m) - s - 这个求和问题有一个封闭形式的解
  • 如果只缺失一个整数,那么你已经完成了 - 报告q
  • 如果缺失多个整数(m-n),你会意识到缺失的整数之和是q,并且其中至少一个整数小于q/(m-n)
  • 从顶部开始,但你只考虑小于q/(m-n)的整数 - 这将成为新的m,只有低于这个最大值的元素才会对新的s和n产生贡献。一直做下去,直到只剩下一个缺失的整数。

然而,这可能不是线性时间,我不确定。


0

编辑:你应该使用候选值加上输入大小的一半作为枢轴来减少常数因子 - 请参见Daniel Schepler的评论 - 但我还没有时间在示例代码中实现它。

这并不是最优解 - 正在寻找一个聪明的解决方案 - 但已足以满足标准 :)

  1. 定义到目前为止可能的最小候选值:1。
  2. 如果输入大小为0,则最小可能的候选值是有效的候选值,因此返回它。
  3. 将输入分成< pivot和> pivot(使用中位数枢轴,例如快速排序)。
  4. 如果≤ pivot的大小小于pivot本身,则其中有一个自由值,因此从步骤2开始重新考虑仅考虑< pivot分区。
  5. 否则(当它= pivot时),新的最小可能候选值是pivot + 1。从步骤2开始重新考虑仅考虑> pivot分区。

我认为这样可以行得通...?

'use strict';

const swap = (arr, i, j) => {
    [arr[i], arr[j]] = [arr[j], arr[i]];
};

// dummy pivot selection, because this part isn’t important
const selectPivot = (arr, start, end) =>
    start + Math.floor(Math.random() * (end - start));

const partition = (arr, start, end) => {
    let mid = selectPivot(arr, start, end);
    const pivot = arr[mid];
    swap(arr, mid, start);
    mid = start;

    for (let i = start + 1; i < end; i++) {
        if (arr[i] < pivot) {
            mid++;
            swap(arr, i, mid);
        }
    }

    swap(arr, mid, start);
    return mid;
};

const findMissing = arr => {
    let candidate = 1;
    let start = 0;
    let end = arr.length;

    for (;;) {
        if (start === end) {
            return candidate;
        }

        const pivotIndex = partition(arr, start, end);
        const pivot = arr[pivotIndex];

        if (pivotIndex + 1 < pivot) {
            end = pivotIndex;
        } else {
            //assert(pivotIndex + 1 === pivot);
            candidate = pivot + 1;
            start = pivotIndex + 1;
        }
    }
};

const createTestCase = (size, max) => {
    if (max < size) {
        throw new Error('size must be < max');
    }

    const arr = Array.from({length: max}, (_, i) => i + 1);
    const expectedIndex = Math.floor(Math.random() * size);
    arr.splice(expectedIndex, 1 + Math.floor(Math.random() * (max - size - 1)));

    for (let i = 0; i < size; i++) {
        let j = i + Math.floor(Math.random() * (size - i));
        swap(arr, i, j);
    }

    return {
        input: arr.slice(0, size),
        expected: expectedIndex + 1,
    };
};

for (let i = 0; i < 5; i++) {
    const test = createTestCase(1000, 1024);
    console.log(findMissing(test.input), test.expected);
}


在长度为n的输入上,您知道输出最多为n+1,因此在n/2处进行枢轴点等操作,并对输出值执行二进制搜索。然后T(n) = O(n) + T(n/2),因此T(n) = O(n)。 - Daniel Schepler
@DanielSchepler:我不确定你所说的二分查找与现有方法的比较,但n/2是更好的选择作为枢轴点,谢谢。 - Ry-
你提到了中位数中的中位数分区选择。我的想法是基于可能的输出值进行分区,而不是将列表在“空间上”划分为粗略的一半。 - Daniel Schepler

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接